add_member.html 74 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595
  1. {% extends "layout.html" %}
  2. {% block title %}{{ '编辑' if member else '录入' }}成员 - 家谱管理系统{% endblock %}
  3. {% block extra_css %}
  4. <style>
  5. .split-container { display: flex; height: calc(100vh - 100px); overflow: hidden; }
  6. .form-panel { flex: 1.2; padding: 20px; overflow-y: auto; border-right: 1px solid #dee2e6; }
  7. .image-panel { flex: 0.8; padding: 20px; background: #f8f9fa; display: flex; flex-direction: column; }
  8. .image-viewer { flex: 1; border: 1px solid #ccc; background: white; overflow: hidden; text-align: center; position: relative; }
  9. .image-viewer img { max-width: 100%; height: auto; transition: transform 0.2s, filter 0.2s; transform-origin: top left; cursor: grab; }
  10. .image-viewer img.dragging { cursor: grabbing; }
  11. /* 放大镜样式 */
  12. .magnifier-glass {
  13. position: absolute;
  14. border: 3px solid #000;
  15. border-radius: 50%;
  16. cursor: none;
  17. width: 150px;
  18. height: 150px;
  19. box-shadow: 0 0 10px rgba(0,0,0,0.5);
  20. display: none;
  21. z-index: 1000;
  22. background-repeat: no-repeat;
  23. background-color: white;
  24. pointer-events: none;
  25. }
  26. /* Image Viewer & Dragging */
  27. .image-viewer {
  28. flex: 1;
  29. border: 1px solid #ccc;
  30. background: #f0f0f0;
  31. overflow: hidden;
  32. text-align: center;
  33. position: relative;
  34. cursor: grab;
  35. user-select: none;
  36. }
  37. .image-viewer:active {
  38. cursor: grabbing;
  39. }
  40. .image-wrapper {
  41. display: inline-block;
  42. transition: transform 0.2s ease-out;
  43. transform-origin: center center;
  44. position: absolute;
  45. /* Initial centering will be handled by JS or CSS translate */
  46. top: 50%;
  47. left: 50%;
  48. transform: translate(-50%, -50%);
  49. }
  50. .image-wrapper img {
  51. max-width: 100%;
  52. max-height: 100vh;
  53. display: block;
  54. pointer-events: none;
  55. user-select: none;
  56. transition: filter 0.2s;
  57. }
  58. .image-toolbar {
  59. background: #e9ecef;
  60. padding: 5px 10px;
  61. border-bottom: 1px solid #dee2e6;
  62. display: flex;
  63. gap: 10px;
  64. align-items: center;
  65. flex-wrap: wrap;
  66. }
  67. .image-toolbar .btn-group-xs > .btn, .image-toolbar .btn-sm {
  68. padding: 0.25rem 0.5rem;
  69. font-size: 0.875rem;
  70. }
  71. .filter-controls { display: flex; align-items: center; gap: 5px; font-size: 0.8rem; }
  72. .filter-controls input[type=range] { width: 80px; }
  73. .page-nav { margin-bottom: 10px; display: flex; gap: 10px; align-items: center; }
  74. .section-title { border-left: 4px solid #0d6efd; padding-left: 10px; margin: 25px 0 15px; font-weight: bold; color: #333; }
  75. </style>
  76. {% endblock %}
  77. {% block content %}
  78. <div class="split-container">
  79. <!-- 左侧:录入/编辑表单 -->
  80. <div class="form-panel">
  81. <div class="card shadow-sm mb-4">
  82. <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
  83. <h5 class="mb-0">{{ '编辑成员信息' if member else '录入新成员' }}</h5>
  84. <a href="{{ url_for('members') }}" class="btn btn-sm btn-light">返回列表</a>
  85. </div>
  86. <div class="card-body">
  87. <form method="POST">
  88. <input type="hidden" name="source_record_id" value="{{ source_record_id if source_record_id else (member.source_record_id if member and member.source_record_id else '') }}">
  89. <input type="hidden" name="source_index" value="">
  90. <div class="section-title">核心信息 (必填)</div>
  91. <div class="row g-3">
  92. <div class="col-md-6">
  93. <label class="form-label">姓名(繁体) <span class="text-danger">*</span></label>
  94. <input type="text" name="name" class="form-control" required value="{{ member.name if member else '' }}">
  95. </div>
  96. <div class="col-md-6">
  97. <label class="form-label">姓名(简体)</label>
  98. <input type="text" name="simplified_name" class="form-control" value="{{ member.simplified_name if member else '' }}">
  99. </div>
  100. <div class="col-md-6">
  101. <label class="form-label">性别 <span class="text-danger">*</span></label>
  102. <select name="sex" class="form-select" required>
  103. <option value="1" {{ 'selected' if member and member.sex == 1 else '' }}>男</option>
  104. <option value="2" {{ 'selected' if member and member.sex == 2 else '' }}>女</option>
  105. </select>
  106. </div>
  107. <div class="col-md-6">
  108. <label class="form-label">出生日期 <span class="text-danger">*</span></label>
  109. <div class="input-group has-validation">
  110. {% set birthday_val = member.birthday_date if member and member.birthday_date != '未知' else '' %}
  111. <input type="date" name="birthday" class="form-control" required value="{{ birthday_val }}" onchange="validateAge()">
  112. <div class="input-group-text bg-white">
  113. <input class="form-check-input mt-0" type="checkbox" id="birthdayUnknown" onchange="toggleBirthdayUnknown()" {{ 'checked' if member and member.birthday_date == '未知' else '' }}>
  114. <label class="form-check-label ms-1 small user-select-none" for="birthdayUnknown">不详</label>
  115. </div>
  116. <div class="invalid-feedback" id="ageFeedback"></div>
  117. </div>
  118. </div>
  119. </div>
  120. <div class="section-title">关系录入 (选择关联成员及关系)</div>
  121. <div class="row g-3">
  122. <div class="col-md-5">
  123. <label class="form-label">关联成员</label>
  124. <select name="related_mid" class="form-select">
  125. <option value="">-- 请选择 --</option>
  126. {% for m in all_members %}
  127. <option value="{{ m.id }}" data-birthday="{{ m.birthday }}" {{ 'selected' if current_relation and current_relation.parent_mid == m.id else '' }}>
  128. {{ m.name }} (ID: {{ m.id }})
  129. </option>
  130. {% endfor %}
  131. </select>
  132. </div>
  133. <div class="col-md-4">
  134. <label class="form-label">关系类型</label>
  135. <select name="relation_type" class="form-select">
  136. <option value="">-- 请选择 --</option>
  137. <option value="1" {{ 'selected' if current_relation and current_relation.relation_type == 1 else '' }}>父子 (关联人为父)</option>
  138. <option value="2" {{ 'selected' if current_relation and current_relation.relation_type == 2 else '' }}>母子 (关联人为母)</option>
  139. <option value="10" {{ 'selected' if current_relation and current_relation.relation_type == 10 else '' }}>夫妻</option>
  140. <option value="11" {{ 'selected' if current_relation and current_relation.relation_type == 11 else '' }}>兄弟</option>
  141. <option value="12" {{ 'selected' if current_relation and current_relation.relation_type == 12 else '' }}>姐妹</option>
  142. </select>
  143. </div>
  144. <div class="col-md-3">
  145. <label class="form-label">子类型</label>
  146. <select name="sub_relation_type" class="form-select">
  147. <option value="0" {{ 'selected' if current_relation and current_relation.sub_relation_type == 0 else '' }}>亲生/正妻</option>
  148. <option value="1" {{ 'selected' if current_relation and current_relation.sub_relation_type == 1 else '' }}>养父</option>
  149. <option value="2" {{ 'selected' if current_relation and current_relation.sub_relation_type == 2 else '' }}>过继</option>
  150. <option value="10" {{ 'selected' if current_relation and current_relation.sub_relation_type == 10 else '' }}>妾</option>
  151. <option value="11" {{ 'selected' if current_relation and current_relation.sub_relation_type == 11 else '' }}>外室</option>
  152. </select>
  153. </div>
  154. </div>
  155. <div class="section-title">谱系详情</div>
  156. <div class="row g-3">
  157. <div class="col-md-4">
  158. <label class="form-label">曾用名</label>
  159. <input type="text" name="former_name" class="form-control" value="{{ member.former_name if member else '' }}">
  160. </div>
  161. <div class="col-md-4">
  162. <label class="form-label">幼名/乳名</label>
  163. <input type="text" name="childhood_name" class="form-control" value="{{ member.childhood_name if member else '' }}">
  164. </div>
  165. <div class="col-md-4">
  166. <label class="form-label">字辈</label>
  167. <input type="text" name="name_word" class="form-control" value="{{ member.name_word if member else '' }}">
  168. </div>
  169. <div class="col-md-4">
  170. <label class="form-label">堂内排行</label>
  171. <input type="text" name="family_rank" class="form-control" value="{{ member.family_rank if member else '' }}">
  172. </div>
  173. <div class="col-md-4">
  174. <label class="form-label">世系世代</label>
  175. <input type="text" name="name_word_generation" class="form-control" value="{{ member.name_word_generation if member else '' }}">
  176. </div>
  177. <div class="col-md-6">
  178. <label class="form-label">名号/封号</label>
  179. <input type="text" name="name_title" class="form-control" value="{{ member.name_title if member else '' }}">
  180. </div>
  181. <div class="col-md-6">
  182. <label class="form-label">分房/堂号</label>
  183. <input type="text" name="branch_family_hall" class="form-control" value="{{ member.branch_family_hall if member else '' }}">
  184. </div>
  185. <div class="col-md-6">
  186. <label class="form-label">聚居地</label>
  187. <input type="text" name="cluster_place" class="form-control" value="{{ member.cluster_place if member else '' }}">
  188. </div>
  189. </div>
  190. <div class="section-title">状态与联系</div>
  191. <div class="row g-3">
  192. <div class="col-md-4">
  193. <label class="form-label">是否过世</label>
  194. <select name="is_pass_away" class="form-select">
  195. <option value="0" {{ 'selected' if member and member.is_pass_away == 0 else '' }}>健在</option>
  196. <option value="1" {{ 'selected' if member and member.is_pass_away == 1 else '' }}>已故</option>
  197. </select>
  198. </div>
  199. <div class="col-md-4">
  200. <label class="form-label">婚姻状况</label>
  201. <select name="marital_status" class="form-select">
  202. <option value="0" {{ 'selected' if member and member.marital_status == 0 else '' }}>未知</option>
  203. <option value="1" {{ 'selected' if member and member.marital_status == 1 else '' }}>未婚</option>
  204. <option value="2" {{ 'selected' if member and member.marital_status == 2 else '' }}>已婚</option>
  205. <option value="3" {{ 'selected' if member and member.marital_status == 3 else '' }}>离异/丧偶</option>
  206. </select>
  207. </div>
  208. <div class="col-md-4">
  209. <label class="form-label">民族</label>
  210. <input type="text" name="nation" class="form-control" value="{{ member.nation if member else '' }}">
  211. </div>
  212. <div class="col-md-6">
  213. <label class="form-label">手机号</label>
  214. <input type="text" name="phone" class="form-control" value="{{ member.phone if member else '' }}">
  215. </div>
  216. <div class="col-md-6">
  217. <label class="form-label">微信号</label>
  218. <input type="text" name="wechat_account" class="form-control" value="{{ member.wechat_account if member else '' }}">
  219. </div>
  220. <div class="col-md-12">
  221. <label class="form-label">现居住址</label>
  222. <input type="text" name="residential_address" class="form-control" value="{{ member.residential_address if member else '' }}">
  223. </div>
  224. </div>
  225. <div class="section-title">个人履历</div>
  226. <div class="row g-3">
  227. <div class="col-md-6">
  228. <label class="form-label">职业</label>
  229. <textarea name="occupation" class="form-control" rows="2">{{ member.occupation if member else '' }}</textarea>
  230. </div>
  231. <div class="col-md-6">
  232. <label class="form-label">教育背景</label>
  233. <textarea name="educational" class="form-control" rows="2">{{ member.educational if member else '' }}</textarea>
  234. </div>
  235. <div class="col-md-12">
  236. <label class="form-label">标签</label>
  237. <input type="text" name="tags" class="form-control" placeholder="例如:抗战老兵, 教师 (用逗号分隔)" value="{{ member.tags if member else '' }}">
  238. </div>
  239. <div class="col-md-12">
  240. <label class="form-label">人员备注</label>
  241. <textarea name="notes" class="form-control" rows="3">{{ member.notes if member else '' }}</textarea>
  242. </div>
  243. <div class="col-md-12">
  244. <label class="form-label">个人成就</label>
  245. <textarea name="personal_achievements" class="form-control" rows="3">{{ member.personal_achievements if member else '' }}</textarea>
  246. </div>
  247. </div>
  248. <div class="d-grid gap-2 mt-5 mb-5">
  249. <button type="submit" class="btn btn-success btn-lg">
  250. <i class="bi bi-check-circle me-1"></i> {{ '保存修改' if member else '确认录入' }}
  251. </button>
  252. </div>
  253. </form>
  254. </div>
  255. </div>
  256. </div>
  257. <!-- AI 推理日志及结果面板 -->
  258. <div id="aiLogPanel" class="position-fixed bottom-0 end-0 p-3 bg-dark text-white shadow"
  259. style="display: none; width: 450px; max-height: 85vh; border-radius: 8px 0 0 0; z-index: 1050; opacity: 0.95; overflow-y: auto;">
  260. <div class="d-flex justify-content-between align-items-center mb-2 border-bottom border-secondary pb-2 sticky-top bg-dark pt-1">
  261. <span class="fw-bold"><i class="bi bi-robot"></i> AI 识别助手</span>
  262. <button class="btn btn-sm btn-outline-light py-0" onclick="closeAiLog()">×</button>
  263. </div>
  264. <!-- 推理过程 -->
  265. <div class="mb-3">
  266. <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">
  267. <i class="bi bi-cpu me-1"></i> 推理过程 <span class="badge bg-secondary ms-2" id="reasoningStatus">进行中...</span>
  268. </button>
  269. <div class="collapse show" id="collapseReasoning">
  270. <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>
  271. </div>
  272. </div>
  273. <!-- 当前选中详情 -->
  274. <div id="aiCurrentDetail" class="mb-3 p-2 bg-secondary bg-opacity-25 rounded border border-info" style="display:none;">
  275. <div class="d-flex justify-content-between align-items-center mb-2 border-bottom border-secondary pb-1">
  276. <strong class="text-info"><i class="bi bi-info-circle"></i> 当前填充详情</strong>
  277. <button class="btn btn-sm btn-link text-muted py-0 text-decoration-none" onclick="document.getElementById('aiCurrentDetail').style.display='none'">×</button>
  278. </div>
  279. <div id="aiDetailContent" class="small text-light" style="word-break: break-all; max-height: 200px; overflow-y: auto;"></div>
  280. </div>
  281. <!-- 识别结果列表 -->
  282. <div id="aiResultSection" style="display: none;">
  283. <div class="d-flex justify-content-between align-items-center mb-2">
  284. <h6 class="mb-0 text-info"><i class="bi bi-check-circle"></i> 识别结果 (<span id="resultCount">0</span>)</h6>
  285. <span class="small text-muted">点击下方条目填充</span>
  286. </div>
  287. <div id="aiResultList" class="d-flex flex-column gap-2">
  288. <!-- 结果项将动态插入 -->
  289. </div>
  290. </div>
  291. </div>
  292. <!-- 右侧:图片参考 -->
  293. <div class="image-panel">
  294. <div class="page-nav">
  295. <label class="fw-bold">扫描件参考:</label>
  296. <button id="aiBtn" onclick="recognizeImage()" class="btn btn-sm btn-info text-white ms-2 me-2">
  297. <i class="bi bi-magic"></i> AI 识别
  298. </button>
  299. <input type="number" id="pageInput" class="form-control form-control-sm" style="width: 70px;" placeholder="页码">
  300. <button onclick="gotoPage()" class="btn btn-sm btn-primary">跳转</button>
  301. <div class="ms-auto small text-muted">
  302. 当前: <span id="currentPage">1</span> / <span id="totalPages">{{ images|length }}</span>
  303. </div>
  304. </div>
  305. <div class="mb-2 small text-muted" id="imageMetadata" style="display: none;">
  306. <span class="me-2"><i class="bi bi-journal-text"></i> 版本名称: <span id="metaVersion">-</span></span>
  307. <span class="me-2"><i class="bi bi-archive"></i> 版本来源: <span id="metaSource">-</span></span>
  308. <span><i class="bi bi-person"></i> 提供人: <span id="metaPerson">-</span></span>
  309. </div>
  310. <div class="image-toolbar rounded-top">
  311. <div class="btn-group btn-group-sm">
  312. <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(-90)" title="左旋90°"><i class="bi bi-arrow-counterclockwise"></i></button>
  313. <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(90)" title="右旋90°"><i class="bi bi-arrow-clockwise"></i></button>
  314. </div>
  315. <div class="filter-controls border-start border-end px-2 mx-1">
  316. <i class="bi bi-brightness-high" title="亮度"></i>
  317. <input type="range" min="50" max="150" value="100" oninput="updateImageFilter()" id="brightnessRange">
  318. <i class="bi bi-circle-half ms-2" title="对比度"></i>
  319. <input type="range" min="50" max="200" value="100" oninput="updateImageFilter()" id="contrastRange">
  320. <button class="btn btn-link btn-sm text-decoration-none py-0" onclick="resetFilters()">重置</button>
  321. </div>
  322. <div class="form-check form-switch ms-auto mb-0" title="开启后鼠标悬停图片可局部放大">
  323. <input class="form-check-input" type="checkbox" id="magnifierSwitch">
  324. <label class="form-check-label small" for="magnifierSwitch">🔍 放大镜</label>
  325. </div>
  326. </div>
  327. <div class="image-viewer shadow-inner" id="viewer">
  328. <div id="magnifier" class="magnifier-glass"></div>
  329. <div id="imageWrapper" class="image-wrapper">
  330. {% if images %}
  331. <img id="refImage" src="{{ images[0].oss_url }}" alt="家谱图片" draggable="false">
  332. {% else %}
  333. <div class="mt-5 text-muted">
  334. <i class="bi bi-image fs-1 d-block mb-2"></i>
  335. 暂无上传的家谱图片
  336. </div>
  337. {% endif %}
  338. </div>
  339. </div>
  340. <div class="mt-2 d-flex justify-content-between">
  341. <button onclick="prevImage()" class="btn btn-sm btn-outline-secondary">上一张</button>
  342. <button onclick="nextImage()" class="btn btn-sm btn-outline-secondary">下一张</button>
  343. </div>
  344. </div>
  345. </div>
  346. {% endblock %}
  347. {% block extra_js %}
  348. <script>
  349. function toggleBirthdayUnknown() {
  350. const cb = document.getElementById('birthdayUnknown');
  351. const input = document.querySelector('input[name="birthday"]');
  352. if (!cb || !input) return;
  353. if (cb.checked) {
  354. input.value = '';
  355. input.disabled = true;
  356. input.required = false;
  357. input.classList.remove('is-invalid');
  358. const fb = document.getElementById('ageFeedback');
  359. if(fb) fb.textContent = '';
  360. } else {
  361. input.disabled = false;
  362. input.required = true;
  363. }
  364. }
  365. function validateAge() {
  366. const cb = document.getElementById('birthdayUnknown');
  367. if (cb && cb.checked) return;
  368. const birthdayInput = document.querySelector('input[name="birthday"]');
  369. const relatedSelect = document.querySelector('select[name="related_mid"]');
  370. const relationType = document.querySelector('select[name="relation_type"]');
  371. const feedback = document.getElementById('ageFeedback');
  372. if (!birthdayInput.value || !relatedSelect.value) {
  373. birthdayInput.classList.remove('is-invalid');
  374. return;
  375. }
  376. // Only check for Parent-Child relations (1: Father, 2: Mother)
  377. if (relationType.value !== '1' && relationType.value !== '2') return;
  378. // We need the parent's birthday. This is tricky as we only have the ID.
  379. // Option 1: Store parent birthdays in the select option dataset (easiest)
  380. // Option 2: Async fetch.
  381. const selectedOption = relatedSelect.options[relatedSelect.selectedIndex];
  382. const parentBirthdayTs = parseInt(selectedOption.dataset.birthday || '0');
  383. if (parentBirthdayTs > 0) {
  384. const childBirthday = new Date(birthdayInput.value).getTime() / 1000;
  385. if (childBirthday < parentBirthdayTs) {
  386. birthdayInput.classList.add('is-invalid');
  387. feedback.textContent = '警告:子女出生日期早于父母,请核对!';
  388. } else if (childBirthday - parentBirthdayTs < 12 * 365 * 24 * 3600) {
  389. // Warning if age gap < 12 years
  390. birthdayInput.classList.add('is-invalid');
  391. feedback.textContent = '警告:父母与子女年龄差小于12岁,请核对!';
  392. } else {
  393. birthdayInput.classList.remove('is-invalid');
  394. }
  395. }
  396. }
  397. // Call validation when relation changes too
  398. document.addEventListener('DOMContentLoaded', () => {
  399. const relatedSelect = document.querySelector('select[name="related_mid"]');
  400. if (relatedSelect) relatedSelect.addEventListener('change', validateAge);
  401. // Initialize birthday unknown state
  402. toggleBirthdayUnknown();
  403. });
  404. const images = [
  405. {% for img in images %}
  406. {
  407. id: {{ img.id }},
  408. url: "{{ img.oss_url }}",
  409. page: {{ img.page_number or 0 }},
  410. ai_status: {{ img.ai_status or 0 }},
  411. ai_content: {{ img.ai_content | tojson | safe if img.ai_content else 'null' }},
  412. genealogy_version: "{{ img.genealogy_version or '' }}",
  413. genealogy_source: "{{ img.genealogy_source or '' }}",
  414. upload_person: "{{ img.upload_person or '' }}"
  415. },
  416. {% endfor %}
  417. ];
  418. let currentIndex = 0;
  419. let currentParsedPeople = [];
  420. // Image State
  421. let imgRotation = 0;
  422. let imgBrightness = 100;
  423. let imgContrast = 100;
  424. // Dragging State
  425. let isDragging = false;
  426. let hasDragged = false;
  427. let startX = 0, startY = 0;
  428. let currentX = 0, currentY = 0; // Relative to center (offsets)
  429. // Zoom State
  430. let isZoomedIn = false;
  431. const ZOOM_LEVEL = 2.0;
  432. // Magnifier Logic
  433. const viewer = document.getElementById('viewer');
  434. const magnifier = document.getElementById('magnifier');
  435. const magnifierSwitch = document.getElementById('magnifierSwitch');
  436. const imageWrapper = document.getElementById('imageWrapper');
  437. // Initialize Dragging and Zooming
  438. if (imageWrapper) {
  439. // Center initial position
  440. imageWrapper.style.left = '50%';
  441. imageWrapper.style.top = '50%';
  442. viewer.style.cursor = 'zoom-in';
  443. viewer.addEventListener('mousedown', (e) => {
  444. if (e.target.closest('.image-toolbar') || e.target.closest('.magnifier-glass')) return;
  445. isDragging = true;
  446. hasDragged = false;
  447. startX = e.clientX;
  448. startY = e.clientY;
  449. viewer.style.cursor = 'grabbing';
  450. e.preventDefault(); // Prevent text selection
  451. });
  452. window.addEventListener('mousemove', (e) => {
  453. if (!isDragging) return;
  454. const dx = e.clientX - startX;
  455. const dy = e.clientY - startY;
  456. // Threshold to consider it a drag
  457. if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
  458. hasDragged = true;
  459. }
  460. currentX += dx;
  461. currentY += dy;
  462. startX = e.clientX;
  463. startY = e.clientY;
  464. updateImageTransform();
  465. });
  466. window.addEventListener('mouseup', (e) => {
  467. if (isDragging) {
  468. isDragging = false;
  469. viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
  470. // If it was a click (not a drag) and clicked inside the viewer
  471. if (!hasDragged && viewer.contains(e.target)) {
  472. toggleZoom();
  473. }
  474. }
  475. });
  476. }
  477. function toggleZoom() {
  478. isZoomedIn = !isZoomedIn;
  479. if (!isZoomedIn) {
  480. // Reset position when zooming out to center
  481. currentX = 0;
  482. currentY = 0;
  483. }
  484. updateImageTransform();
  485. // Update cursor immediately
  486. viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
  487. }
  488. viewer.addEventListener('mousemove', function(e) {
  489. if (!magnifierSwitch.checked) {
  490. magnifier.style.display = 'none';
  491. return;
  492. }
  493. if (isDragging) {
  494. magnifier.style.display = 'none';
  495. return;
  496. }
  497. const img = document.getElementById('refImage');
  498. if (!img) return;
  499. // Calculate position relative to the image
  500. const rect = img.getBoundingClientRect();
  501. const x = e.clientX - rect.left;
  502. const y = e.clientY - rect.top;
  503. // Only show if inside image rect (approximate for rotated)
  504. if (x < 0 || x > rect.width || y < 0 || y > rect.height) {
  505. magnifier.style.display = 'none';
  506. return;
  507. }
  508. magnifier.style.display = 'block';
  509. // Position the glass near mouse
  510. const glassOffset = 20;
  511. const viewerRect = viewer.getBoundingClientRect();
  512. magnifier.style.left = (e.clientX - viewerRect.left + glassOffset) + 'px';
  513. magnifier.style.top = (e.clientY - viewerRect.top + glassOffset) + 'px';
  514. // Background logic (Zoom 2x)
  515. const zoom = 2.5;
  516. magnifier.style.backgroundImage = `url('${img.src}')`;
  517. magnifier.style.backgroundSize = `${rect.width * zoom}px ${rect.height * zoom}px`;
  518. // Simple version (imperfect for rotation)
  519. magnifier.style.backgroundPosition = `-${x * zoom - 75}px -${y * zoom - 75}px`;
  520. });
  521. // Image Manipulation
  522. function rotateImage(deg) {
  523. imgRotation = (imgRotation + deg) % 360;
  524. updateImageTransform();
  525. }
  526. function updateImageFilter() {
  527. imgBrightness = document.getElementById('brightnessRange').value;
  528. imgContrast = document.getElementById('contrastRange').value;
  529. applyImageFilters();
  530. }
  531. function resetFilters() {
  532. imgRotation = 0;
  533. imgBrightness = 100;
  534. imgContrast = 100;
  535. currentX = 0;
  536. currentY = 0;
  537. isZoomedIn = false;
  538. document.getElementById('brightnessRange').value = 100;
  539. document.getElementById('contrastRange').value = 100;
  540. updateImageTransform();
  541. applyImageFilters();
  542. }
  543. function updateImageTransform() {
  544. const wrapper = document.getElementById('imageWrapper');
  545. if (wrapper) {
  546. const scale = isZoomedIn ? ZOOM_LEVEL : 1;
  547. wrapper.style.transform = `translate(calc(-50% + ${currentX}px), calc(-50% + ${currentY}px)) rotate(${imgRotation}deg) scale(${scale})`;
  548. // Adjust cursor based on state
  549. if (!isDragging) {
  550. viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
  551. }
  552. }
  553. }
  554. function applyImageFilters() {
  555. const img = document.getElementById('refImage');
  556. if (img) {
  557. img.style.filter = `brightness(${imgBrightness}%) contrast(${imgContrast}%)`;
  558. }
  559. }
  560. // Reuse applyImageStyles as alias for compatibility if called elsewhere
  561. function applyImageStyles() {
  562. updateImageTransform();
  563. applyImageFilters();
  564. }
  565. const fieldMapping = {
  566. name: '姓名(繁体)',
  567. simplified_name: '姓名(简体)',
  568. sex: '性别',
  569. birthday: '出生日期',
  570. father_name: '父亲姓名',
  571. spouse_name: '配偶姓名',
  572. generation: '堂内排行(代数)',
  573. name_word: '字辈',
  574. education: '学历/功名',
  575. title: '官职/称号',
  576. death_date: '逝世日期',
  577. note: '备注'
  578. };
  579. // --- Keyboard Shortcuts ---
  580. document.addEventListener('keydown', (e) => {
  581. // Ctrl/Cmd + Enter: Save
  582. if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
  583. e.preventDefault();
  584. const submitBtn = document.querySelector('form button[type="submit"]');
  585. if (submitBtn && !submitBtn.disabled) {
  586. submitBtn.click(); // Trigger form submit listener
  587. }
  588. }
  589. // Ctrl/Cmd + Right Arrow: Next Image
  590. if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowRight') {
  591. e.preventDefault();
  592. nextImage();
  593. }
  594. // Ctrl/Cmd + Left Arrow: Prev Image
  595. if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowLeft') {
  596. e.preventDefault();
  597. prevImage();
  598. }
  599. // Alt + 1: Auto Fill First person in list
  600. if (e.altKey && e.key === '1') {
  601. e.preventDefault();
  602. // Try to find the first "fill" button that is not disabled/success
  603. const firstBtn = document.querySelector('button[id^="btn-fill-"]:not(.btn-success)');
  604. if (firstBtn) firstBtn.click();
  605. }
  606. });
  607. // --- AJAX Form Submission ---
  608. document.addEventListener('DOMContentLoaded', () => {
  609. const form = document.querySelector('form');
  610. form.addEventListener('submit', async (e) => {
  611. e.preventDefault();
  612. // Collect form data
  613. const formData = new FormData(form);
  614. // Visual feedback on button
  615. const submitBtn = form.querySelector('button[type="submit"]');
  616. const originalBtnHtml = submitBtn.innerHTML;
  617. submitBtn.disabled = true;
  618. submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 保存中...';
  619. try {
  620. // Use form.action to support both add and edit URLs
  621. const targetUrl = form.action || window.location.href;
  622. const response = await fetch(targetUrl, {
  623. method: 'POST',
  624. body: formData,
  625. headers: {
  626. 'X-Requested-With': 'XMLHttpRequest'
  627. }
  628. });
  629. const result = await response.json();
  630. if (result.success) {
  631. // Success!
  632. // 1. Show a toast or small alert
  633. const toast = document.createElement('div');
  634. toast.className = 'position-fixed bottom-0 start-50 translate-middle-x mb-4 p-3 bg-success text-white rounded shadow';
  635. toast.style.zIndex = '2000';
  636. toast.innerHTML = `<i class="bi bi-check-circle me-2"></i> ${result.message}`;
  637. document.body.appendChild(toast);
  638. setTimeout(() => toast.remove(), 3000);
  639. // 2. Mark the AI list item as "Saved" if applicable
  640. if (window.lastFilledIndex !== undefined) {
  641. const btn = document.getElementById(`btn-fill-${window.lastFilledIndex}`);
  642. if (btn) {
  643. btn.className = 'btn btn-sm btn-success text-white ms-2 disabled';
  644. btn.innerHTML = '<i class="bi bi-check-lg"></i> 已录入';
  645. btn.onclick = null;
  646. }
  647. // Update local data state so it persists if we switch images/filters
  648. if (currentParsedPeople[window.lastFilledIndex]) {
  649. currentParsedPeople[window.lastFilledIndex].is_imported = true;
  650. currentParsedPeople[window.lastFilledIndex].imported_member_id = result.member_id;
  651. // Sync back to images array to persist across image switching
  652. if (images[currentIndex]) {
  653. images[currentIndex].ai_content = currentParsedPeople;
  654. }
  655. }
  656. }
  657. // 3. Clear form (reset to defaults) or keep some fields?
  658. // Usually for genealogy, Surname/Generation might be same, but let's clear for safety
  659. // Resetting form but keeping "related_mid" might be useful for siblings?
  660. // For now, simple reset.
  661. // --- Update Local Matches before resetting form ---
  662. // If we just saved a person, check if this person is the father/spouse of anyone else in the list
  663. // and update their matches so 'fillForm' will work for them.
  664. const savedName = formData.get('name'); // Traditional (Raw)
  665. const savedSimplifiedName = formData.get('simplified_name'); // Simplified (Cleaned)
  666. const savedId = result.member_id;
  667. const savedSex = formData.get('sex'); // 1: Male, 2: Female
  668. if (savedId) {
  669. currentParsedPeople.forEach(p => {
  670. if (!p.matches) p.matches = {};
  671. // Check Father Match
  672. // Try matching against Simplified Name (p.father_name is Simplified Cleaned)
  673. // Or fallback to savedName if p.father_name happened to be Traditional (rare but possible)
  674. if (p.father_name && (p.father_name === savedSimplifiedName || p.father_name === savedName)) {
  675. // Assume simple match logic here (usually father is male)
  676. if (savedSex === '1') {
  677. if (!p.matches.father) p.matches.father = [];
  678. // Add to matches if not exists
  679. if (!p.matches.father.find(m => m.id === savedId)) {
  680. p.matches.father.push({ id: savedId, name: savedName, sex: 1 }); // Mock DB object
  681. }
  682. }
  683. }
  684. // Check Spouse Match
  685. if (p.spouse_name && (p.spouse_name === savedSimplifiedName || p.spouse_name === savedName)) {
  686. // Spouse logic...
  687. if (!p.matches.spouse) p.matches.spouse = [];
  688. if (!p.matches.spouse.find(m => m.id === savedId)) {
  689. p.matches.spouse.push({ id: savedId, name: savedName, sex: parseInt(savedSex) });
  690. }
  691. }
  692. });
  693. // Also, we need to add this new member to the <select> options for future manual selection!
  694. // This is tricky because the select is rendered by Jinja2.
  695. // We can append an option via JS.
  696. const relatedSelect = document.querySelector('select[name="related_mid"]');
  697. if (relatedSelect) {
  698. const newOption = document.createElement('option');
  699. newOption.value = savedId;
  700. newOption.textContent = `${savedName} (ID: ${savedId})`;
  701. // Add birthday data if available for validation
  702. newOption.dataset.birthday = new Date(formData.get('birthday')).getTime() / 1000;
  703. relatedSelect.add(newOption); // Add to end
  704. }
  705. }
  706. // --- End Local Match Update ---
  707. form.reset();
  708. // Clear hidden/custom fields if any manually
  709. form.querySelector('[name="name_word_generation"]').value = '';
  710. form.querySelector('[name="personal_achievements"]').value = '';
  711. form.querySelector('[name="notes"]').value = '';
  712. form.querySelector('[name="tags"]').value = '';
  713. form.querySelector('[name="family_rank"]').value = '';
  714. // Close detail panel
  715. document.getElementById('aiCurrentDetail').style.display = 'none';
  716. // 4. Auto-Next Logic
  717. // Find the next available person in the list to fill
  718. if (window.lastFilledIndex !== undefined) {
  719. const nextIndex = window.lastFilledIndex + 1;
  720. if (currentParsedPeople[nextIndex]) {
  721. // Automatically fill the next one!
  722. fillForm(nextIndex);
  723. // Scroll list to show the new active item if needed
  724. const btn = document.getElementById(`btn-fill-${nextIndex}`);
  725. if(btn) btn.scrollIntoView({ behavior: 'smooth', block: 'center' });
  726. }
  727. }
  728. } else {
  729. alert('保存失败: ' + result.message);
  730. }
  731. } catch (error) {
  732. console.error('Error submitting form:', error);
  733. alert('网络或服务器错误,请稍后重试');
  734. } finally {
  735. submitBtn.disabled = false;
  736. submitBtn.innerHTML = originalBtnHtml;
  737. }
  738. });
  739. });
  740. // --- End AJAX Form Submission ---
  741. function updateDisplay() {
  742. if (images.length > 0) {
  743. const img = images[currentIndex];
  744. document.getElementById('refImage').src = img.url;
  745. document.getElementById('currentPage').innerText = currentIndex + 1;
  746. // Update metadata display
  747. const metaContainer = document.getElementById('imageMetadata');
  748. if (img.genealogy_version || img.genealogy_source || img.upload_person) {
  749. metaContainer.style.display = 'block';
  750. document.getElementById('metaVersion').innerText = img.genealogy_version || '未提供';
  751. document.getElementById('metaSource').innerText = img.genealogy_source || '未提供';
  752. document.getElementById('metaPerson').innerText = img.upload_person || '未提供';
  753. } else {
  754. metaContainer.style.display = 'none';
  755. }
  756. // Reset image state on switch
  757. resetFilters();
  758. // AI Button Logic
  759. const aiBtn = document.getElementById('aiBtn');
  760. const aiPanel = document.getElementById('aiLogPanel');
  761. const resultList = document.getElementById('aiResultList');
  762. const resultCount = document.getElementById('resultCount');
  763. // Hide panel when switching images to avoid confusion
  764. if (aiPanel) aiPanel.style.display = 'none';
  765. // Clear current data
  766. currentParsedPeople = [];
  767. if (resultCount) resultCount.innerText = '0';
  768. if (resultList) resultList.innerHTML = '';
  769. if (img.ai_status === 2 && img.ai_content) {
  770. // Determine content
  771. let content = img.ai_content;
  772. // Parse if string (it might be a string if double encoded or stored as JSON string in DB)
  773. if (typeof content === 'string') {
  774. try { content = JSON.parse(content); } catch(e) { content = []; }
  775. }
  776. if (!Array.isArray(content) && content) content = [content];
  777. if (content && content.length > 0) {
  778. // Update Button to "View Results"
  779. aiBtn.innerHTML = '<i class="bi bi-list-check"></i> 查看解析结果';
  780. aiBtn.className = 'btn btn-sm btn-success text-white ms-2 me-2';
  781. aiBtn.onclick = function() {
  782. // Show panel with loading
  783. if (aiPanel) aiPanel.style.display = 'block';
  784. if (resultList) resultList.innerHTML = '<div class="text-center p-3"><div class="spinner-border text-primary" role="status"></div></div>';
  785. // Process (small delay to allow UI update)
  786. setTimeout(() => processAiData(content), 10);
  787. };
  788. return; // Done
  789. }
  790. }
  791. // Default: Reset to "AI Recognition"
  792. aiBtn.innerHTML = '<i class="bi bi-magic"></i> AI 识别';
  793. aiBtn.className = 'btn btn-sm btn-info text-white ms-2 me-2';
  794. aiBtn.onclick = recognizeImage;
  795. }
  796. }
  797. function nextImage() {
  798. if (currentIndex < images.length - 1) {
  799. currentIndex++;
  800. updateDisplay();
  801. }
  802. }
  803. function prevImage() {
  804. if (currentIndex > 0) {
  805. currentIndex--;
  806. updateDisplay();
  807. }
  808. }
  809. function gotoPage() {
  810. const val = document.getElementById('pageInput').value;
  811. if (!val) return;
  812. const page = parseInt(val);
  813. const index = images.findIndex(img => img.page === page);
  814. if (index !== -1) {
  815. currentIndex = index;
  816. updateDisplay();
  817. } else {
  818. alert('未找到该页码对应的图片');
  819. }
  820. }
  821. function closeAiLog() {
  822. document.getElementById('aiLogPanel').style.display = 'none';
  823. }
  824. function toggleAiPanel() {
  825. const panel = document.getElementById('aiLogPanel');
  826. if (panel.style.display === 'none') {
  827. panel.style.display = 'block';
  828. } else {
  829. panel.style.display = 'none';
  830. }
  831. }
  832. function updateAiButtonState(hasResults) {
  833. const btn = document.getElementById('aiBtn');
  834. if (!btn) return;
  835. if (hasResults) {
  836. btn.innerHTML = '<i class="bi bi-list-check"></i> 查看识别结果';
  837. btn.onclick = toggleAiPanel;
  838. btn.classList.remove('btn-info');
  839. btn.classList.add('btn-success');
  840. } else {
  841. // Revert state if needed (usually on new image load if we clear data)
  842. btn.innerHTML = '<i class="bi bi-magic"></i> AI 识别';
  843. btn.onclick = recognizeImage;
  844. btn.classList.remove('btn-success');
  845. btn.classList.add('btn-info');
  846. }
  847. }
  848. function fillForm(index) {
  849. window.lastFilledIndex = index;
  850. const person = currentParsedPeople[index];
  851. if (!person) return;
  852. const form = document.querySelector('form');
  853. form.reset(); // Clear previous data first
  854. // Set Source Index
  855. const sourceIndexInput = form.querySelector('[name="source_index"]');
  856. if (sourceIndexInput) sourceIndexInput.value = index;
  857. // 1. 姓名
  858. if (person.name) form.querySelector('[name="name"]').value = person.name;
  859. if (person.simplified_name) {
  860. const snInput = form.querySelector('[name="simplified_name"]');
  861. if (snInput) snInput.value = person.simplified_name;
  862. } else {
  863. // Fallback: if no simplified_name explicitly, generate it
  864. if (person.name) {
  865. const snInput = form.querySelector('[name="simplified_name"]');
  866. if (snInput) snInput.value = cleanName(person.name);
  867. }
  868. }
  869. // 2. 性别
  870. if (person.sex) {
  871. const sexSelect = form.querySelector('[name="sex"]');
  872. if (person.sex.includes('女')) sexSelect.value = '2';
  873. else if (person.sex.includes('男')) sexSelect.value = '1';
  874. }
  875. // 3. 生日 & 自动推断过世
  876. // Reset unknown toggle first
  877. const birthdayUnknownCb = document.getElementById('birthdayUnknown');
  878. if (birthdayUnknownCb) {
  879. birthdayUnknownCb.checked = false;
  880. toggleBirthdayUnknown();
  881. }
  882. if (person.birthday) {
  883. let dateVal = person.birthday;
  884. // 尝试标准化
  885. const dateMatch = dateVal.match(/(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})/);
  886. if (dateMatch) {
  887. const y = dateMatch[1];
  888. const m = dateMatch[2].padStart(2, '0');
  889. const d = dateMatch[3].padStart(2, '0');
  890. dateVal = `${y}-${m}-${d}`;
  891. // Auto "Is Deceased" Logic (e.g. older than 100 years from now)
  892. const birthYear = parseInt(y);
  893. const currentYear = new Date().getFullYear();
  894. if (currentYear - birthYear > 100) {
  895. const passAwaySelect = form.querySelector('[name="is_pass_away"]');
  896. if (passAwaySelect) passAwaySelect.value = '1';
  897. }
  898. }
  899. // 只有当日期格式正确时才填充,否则不填或者留给用户
  900. if (/^\d{4}-\d{2}-\d{2}$/.test(dateVal)) {
  901. form.querySelector('[name="birthday"]').value = dateVal;
  902. }
  903. }
  904. // 4. 代数 -> 堂内排行
  905. if (person.generation) {
  906. const genMatch = person.generation.match(/\d+/);
  907. // 这里将 AI 解析的 'generation' 填入 'family_rank' (堂内排行)
  908. // 'name_word_generation' (世系世代) 保持为空
  909. form.querySelector('[name="family_rank"]').value = person.generation;
  910. }
  911. // 4.5 字辈 (name_word)
  912. let zibei = person.name_word;
  913. if (!zibei && person.name) {
  914. // Heuristic: If name starts with "留" and is 3 chars long (e.g. 留学勤), Zibei is index 1.
  915. // If name starts with "留" and is > 3 chars, we can't be sure, but index 1 is a good guess for generation char.
  916. // "留学公" -> "留" + "学" + "公". Zibei "学".
  917. // "留学勤" -> "留" + "学" + "勤". Zibei "学".
  918. // Let's use a safe heuristic: if name starts with '留' and length >= 3
  919. if (person.name.startsWith('留') && person.name.length >= 3) {
  920. zibei = person.name.charAt(1);
  921. }
  922. }
  923. if (zibei) {
  924. form.querySelector('[name="name_word"]').value = zibei;
  925. person.name_word = zibei; // Update data object for display
  926. }
  927. // 5. 其他信息
  928. if (person.education) form.querySelector('[name="educational"]').value = person.education;
  929. if (person.title) form.querySelector('[name="occupation"]').value = person.title;
  930. // 个人成就/备注字段追加信息
  931. let extraInfo = [];
  932. if (person.father_name) extraInfo.push(`父亲: ${person.father_name}`);
  933. if (person.spouse_name) extraInfo.push(`配偶: ${person.spouse_name}`);
  934. // 将亲属关系存入 'notes' (人员备注) 字段
  935. const notesField = form.querySelector('[name="notes"]');
  936. const currentNotes = notesField.value;
  937. const newInfo = extraInfo.join('; ');
  938. if (newInfo && !currentNotes.includes(newInfo)) {
  939. notesField.value = currentNotes ? (currentNotes + '\n' + newInfo) : newInfo;
  940. }
  941. // --- Auto-Linking Logic ---
  942. if (person.matches) {
  943. // Priority: Father > Spouse (Configurable?)
  944. // For now, if father matches, select father.
  945. if (person.matches.father && person.matches.father.length > 0) {
  946. // Pick the first one for now (could show UI to choose if multiple)
  947. const father = person.matches.father[0];
  948. const relSelect = form.querySelector('[name="related_mid"]');
  949. const relTypeSelect = form.querySelector('[name="relation_type"]');
  950. if (relSelect && relTypeSelect) {
  951. relSelect.value = father.id;
  952. relTypeSelect.value = '1'; // 父子
  953. // Trigger change event if needed by other logic (not needed here yet)
  954. }
  955. } else if (person.matches.spouse && person.matches.spouse.length > 0) {
  956. const spouse = person.matches.spouse[0];
  957. const relSelect = form.querySelector('[name="related_mid"]');
  958. const relTypeSelect = form.querySelector('[name="relation_type"]');
  959. if (relSelect && relTypeSelect) {
  960. relSelect.value = spouse.id;
  961. relTypeSelect.value = '10'; // 夫妻
  962. }
  963. }
  964. }
  965. // --- Show Details Panel ---
  966. const detailContainer = document.getElementById('aiCurrentDetail');
  967. const detailContent = document.getElementById('aiDetailContent');
  968. let html = '<ul class="list-unstyled mb-0 font-monospace" style="font-size: 0.85rem;">';
  969. const getLabel = (k) => fieldMapping[k] || (k === 'children' ? '子女' : k);
  970. // 遍历属性显示
  971. for (const key in person) {
  972. // 隐藏内部属性
  973. if (key.startsWith('_')) continue;
  974. let val = person[key];
  975. const label = getLabel(key);
  976. // 特殊处理 children
  977. if (key === 'children') {
  978. if (Array.isArray(val) && val.length > 0) {
  979. let childrenHtml = '<div class="d-flex flex-wrap gap-1 mt-1">';
  980. val.forEach(child => {
  981. // 使用 child._originalIndex 进行跳转填充
  982. 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>`;
  983. });
  984. childrenHtml += '</div>';
  985. html += `<li class="mb-1"><span class="text-info opacity-75">${label}:</span> ${childrenHtml}</li>`;
  986. }
  987. continue;
  988. }
  989. // 默认显示
  990. if (!val || val === '') val = '-';
  991. html += `<li class="mb-1"><span class="text-info opacity-75">${label}:</span> <span class="text-white ms-1">${val}</span></li>`;
  992. }
  993. html += '</ul>';
  994. detailContent.innerHTML = html;
  995. detailContainer.style.display = 'block';
  996. // Visual feedback
  997. const btn = document.getElementById(`btn-fill-${index}`);
  998. if(btn) {
  999. const originalHtml = btn.innerHTML;
  1000. btn.innerHTML = '<i class="bi bi-check"></i> 已填';
  1001. btn.classList.remove('btn-outline-info');
  1002. btn.classList.add('btn-info', 'text-white');
  1003. setTimeout(() => {
  1004. btn.innerHTML = originalHtml;
  1005. btn.classList.add('btn-outline-info');
  1006. btn.classList.remove('btn-info', 'text-white');
  1007. }, 1000);
  1008. }
  1009. }
  1010. // --- Pre-fill Logic from Backend (Async AI Result) ---
  1011. const prefilledContent = {{ prefilled_content | tojson | safe if prefilled_content else 'null' }};
  1012. const sourceOssUrl = "{{ source_oss_url if source_oss_url else '' }}";
  1013. const sourceRecordId = "{{ source_record_id if source_record_id else '' }}";
  1014. if (prefilledContent && sourceOssUrl) {
  1015. // We have prefilled content from DB, simulate "Recognize Image" success
  1016. document.addEventListener('DOMContentLoaded', async () => {
  1017. // Wait a bit for UI to settle
  1018. setTimeout(async () => {
  1019. // Find image index
  1020. const imgIndex = images.findIndex(img => img.url === sourceOssUrl);
  1021. if (imgIndex !== -1) {
  1022. currentIndex = imgIndex;
  1023. updateDisplay();
  1024. }
  1025. // Parse and display results
  1026. try {
  1027. let data = prefilledContent;
  1028. if (typeof data === 'string') {
  1029. try {
  1030. data = JSON.parse(data);
  1031. } catch(e) {
  1032. console.error("Prefilled content parse error", e);
  1033. return;
  1034. }
  1035. }
  1036. if (!Array.isArray(data)) data = [data];
  1037. await processAiData(data);
  1038. // Open the log panel to show results
  1039. const aiPanel = document.getElementById('aiLogPanel');
  1040. if (aiPanel) aiPanel.style.display = 'block';
  1041. const status = document.getElementById('reasoningStatus');
  1042. if(status) {
  1043. status.textContent = '已加载历史解析';
  1044. status.className = 'badge bg-success ms-2';
  1045. }
  1046. const logContent = document.getElementById('aiLogContent');
  1047. if(logContent) logContent.textContent = "已加载历史 AI 解析记录。";
  1048. } catch (e) {
  1049. console.error("Error processing prefilled content", e);
  1050. }
  1051. }, 500);
  1052. });
  1053. } else {
  1054. // No prefilled content, initialize display for the first image
  1055. document.addEventListener('DOMContentLoaded', () => {
  1056. updateDisplay();
  1057. });
  1058. }
  1059. // --- Name Cleaning Logic (Matching Backend) ---
  1060. // 仅做繁 -> 简转换,不动姓氏/“公”处理,用于配偶等非留氏族人
  1061. function manualSimplify(text) {
  1062. if (!text) return text;
  1063. text = text.trim();
  1064. const mapping = {
  1065. '學': '学', '國': '国', '萬': '万', '寶': '宝', '興': '兴',
  1066. '華': '华', '會': '会', '葉': '叶', '藝': '艺', '號': '号',
  1067. '處': '处', '見': '见', '視': '视', '言': '言', '語': '语',
  1068. '貝': '贝', '車': '车', '長': '长', '門': '门', '韋': '韦',
  1069. '頁': '页', '風': '风', '飛': '飞', '食': '食', '馬': '马',
  1070. '魚': '鱼', '鳥': '鸟', '麥': '麦', '黃': '黄', '齊': '齐',
  1071. '齒': '齿', '龍': '龙', '龜': '龟', '壽': '寿', '榮': '荣',
  1072. '愛': '爱', '慶': '庆', '衛': '卫', '賢': '贤', '義': '义',
  1073. '禮': '礼', '樂': '乐', '靈': '灵', '滅': '灭', '氣': '气',
  1074. '智': '智', '信': '信', '仁': '仁', '勇': '勇', '嚴': '严',
  1075. '劉': '刘'
  1076. };
  1077. let result = '';
  1078. for (const ch of text) {
  1079. result += mapping[ch] || ch;
  1080. }
  1081. return result;
  1082. }
  1083. // 留氏本人姓名清洗:在 manualSimplify 基础上,处理“留”姓和“公”
  1084. function cleanName(name) {
  1085. if (!name) return name;
  1086. name = manualSimplify(name);
  1087. const exceptions = ['学公', '留学公'];
  1088. if (exceptions.includes(name)) {
  1089. if (!name.startsWith('留')) {
  1090. name = '留' + name;
  1091. }
  1092. return name;
  1093. }
  1094. // Remove '公' suffix
  1095. if (name.endsWith('公')) {
  1096. name = name.slice(0, -1);
  1097. }
  1098. // Ensure '留' prefix
  1099. if (!name.startsWith('留')) {
  1100. name = '留' + name;
  1101. }
  1102. return name;
  1103. }
  1104. // Extracted function to process AI data and render tree
  1105. async function processAiData(data) {
  1106. // Clean Names First
  1107. data.forEach(p => {
  1108. // Determine "Original" (Raw) and "Simplified" (Cleaned)
  1109. let rawName = p.original_name || p.name;
  1110. let simName = p.name; // This is the Simplified one from AI if original_name exists
  1111. // Clean the Simplified Name(本人:带“留”姓规则)
  1112. p.simplified_name = cleanName(simName);
  1113. // Set the name to be the Raw Name for storage in 'name' column
  1114. p.name = rawName;
  1115. // 父亲:同族,用 cleanName(加“留”、去“公”)
  1116. if (p.father_name) p.father_name = cleanName(p.father_name);
  1117. // 配偶:只做繁体 -> 简体,不拼接“留”姓
  1118. if (p.spouse_name) p.spouse_name = manualSimplify(p.spouse_name);
  1119. });
  1120. // Call Relation Check API
  1121. try {
  1122. // Send simplified_name for checking relations if available, or name?
  1123. // The API checks against DB 'name' column.
  1124. // Wait, DB 'name' column is now Traditional Raw.
  1125. // But existing data in DB is Simplified Cleaned.
  1126. // New data will be Traditional Raw in 'name', Simplified Cleaned in 'simplified_name'.
  1127. // The check_relations API uses `WHERE name IN (...)`.
  1128. // The AI returns `father_name` as Simplified (usually).
  1129. // So we are checking Simplified Father Name against...
  1130. // If DB 'name' is mixed (Old Simplified, New Traditional), this is messy.
  1131. // But `check_relations` logic:
  1132. // `names_to_check.add(p['father_name'])` -> Simplified.
  1133. // `SELECT ... WHERE name IN ...`
  1134. // If DB 'name' contains Traditional, we won't find match if we search Simplified.
  1135. // Unless we search `simplified_name` column too?
  1136. // I should update `check_relations` in app.py to search both `name` and `simplified_name`.
  1137. const checkRes = await fetch('/manager/api/check_relations', {
  1138. method: 'POST',
  1139. headers: { 'Content-Type': 'application/json' },
  1140. body: JSON.stringify({ people: data })
  1141. });
  1142. const checkResult = await checkRes.json();
  1143. if (checkResult.success && checkResult.matches) {
  1144. // Merge matches into data
  1145. for (const idx in checkResult.matches) {
  1146. const match = checkResult.matches[idx];
  1147. if (data[idx]) {
  1148. data[idx].matches = match;
  1149. }
  1150. }
  1151. }
  1152. } catch (e) {
  1153. console.warn("Auto-linking failed:", e);
  1154. }
  1155. currentParsedPeople = data;
  1156. document.getElementById('resultCount').innerText = data.length;
  1157. // Update Button State to "View Results"
  1158. updateAiButtonState(true);
  1159. // Build Relationship Tree
  1160. const personMap = {};
  1161. const roots = [];
  1162. // 1. Initialize map
  1163. data.forEach((p, index) => {
  1164. p._originalIndex = index; // Store original index for fillForm
  1165. p.children = [];
  1166. // Use simplified_name as key if available, otherwise name (for consistent lookup)
  1167. const lookupKey = p.simplified_name || p.name;
  1168. personMap[lookupKey] = p;
  1169. });
  1170. // 2. Build Hierarchy
  1171. data.forEach(p => {
  1172. let parentFound = false;
  1173. if (p.father_name) {
  1174. // Try exact match using simplified name (since father_name is usually simplified)
  1175. let father = personMap[p.father_name];
  1176. // Try loose match
  1177. if (!father) {
  1178. for (const name in personMap) {
  1179. if (name.includes(p.father_name) || p.father_name.includes(name)) {
  1180. father = personMap[name];
  1181. break;
  1182. }
  1183. }
  1184. }
  1185. if (father && father !== p) {
  1186. father.children.push(p);
  1187. parentFound = true;
  1188. }
  1189. }
  1190. if (!parentFound) {
  1191. roots.push(p);
  1192. }
  1193. });
  1194. // 3. Recursive Render Function
  1195. function renderNode(p, level = 0) {
  1196. const indent = level * 20;
  1197. let html = `
  1198. <div class="card bg-dark border-secondary mb-1" style="margin-left: ${indent}px; background-color: #2c3034;">
  1199. <div class="card-body p-2 d-flex justify-content-between align-items-center">
  1200. <div class="text-white">
  1201. <div class="fw-bold">
  1202. ${level > 0 ? '<i class="bi bi-arrow-return-right text-secondary me-1"></i>' : ''}
  1203. ${p.name || '未知姓名'}
  1204. <span class="badge bg-secondary text-light ms-1" style="font-size: 0.7rem">${p.sex || '-'}</span>
  1205. </div>
  1206. <div class="small text-white-50" style="font-size: 0.75rem; padding-left: ${level > 0 ? 18 : 0}px;">
  1207. ${p.generation ? '第'+p.generation+'世 ' : ''}
  1208. ${p.father_name ? '父:'+p.father_name : ''}
  1209. </div>
  1210. </div>
  1211. <button id="btn-fill-${p._originalIndex}"
  1212. class="btn btn-sm ${p.is_imported ? 'btn-success disabled' : 'btn-outline-info'} text-nowrap ms-2"
  1213. onclick="${p.is_imported ? '' : `fillForm(${p._originalIndex})`}">
  1214. ${p.is_imported ? '<i class="bi bi-check-lg"></i> 已录入' : '<i class="bi bi-pencil-square"></i> 填充'}
  1215. </button>
  1216. </div>
  1217. </div>
  1218. `;
  1219. if (p.children && p.children.length > 0) {
  1220. p.children.forEach(child => {
  1221. html += renderNode(child, level + 1);
  1222. });
  1223. }
  1224. return html;
  1225. }
  1226. // Render List
  1227. const resultList = document.getElementById('aiResultList');
  1228. const resultSection = document.getElementById('aiResultSection');
  1229. resultList.innerHTML = '';
  1230. // Fix: Use data directly if root finding logic fails or returns empty but data exists
  1231. if (roots.length === 0 && data.length > 0) {
  1232. // Just dump everything flat if tree building fails
  1233. data.forEach(p => resultList.innerHTML += renderNode(p, 0));
  1234. } else {
  1235. roots.forEach(p => {
  1236. resultList.innerHTML += renderNode(p, 0);
  1237. });
  1238. }
  1239. resultSection.style.display = 'block';
  1240. }
  1241. async function recognizeImage() {
  1242. if (images.length === 0) {
  1243. alert('没有可用的图片');
  1244. return;
  1245. }
  1246. const currentImg = images[currentIndex];
  1247. const btn = document.getElementById('aiBtn');
  1248. const originalContent = btn.innerHTML;
  1249. const logPanel = document.getElementById('aiLogPanel');
  1250. const logContent = document.getElementById('aiLogContent');
  1251. const resultSection = document.getElementById('aiResultSection');
  1252. const resultList = document.getElementById('aiResultList');
  1253. const reasoningStatus = document.getElementById('reasoningStatus');
  1254. btn.disabled = true;
  1255. btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 识别中...';
  1256. // Reset UI
  1257. logContent.textContent = '';
  1258. resultList.innerHTML = '';
  1259. resultSection.style.display = 'none';
  1260. logPanel.style.display = 'block';
  1261. reasoningStatus.textContent = '连接中...';
  1262. reasoningStatus.className = 'badge bg-secondary ms-2';
  1263. // Ensure reasoning panel is open
  1264. const collapseReasoning = document.getElementById('collapseReasoning');
  1265. if (collapseReasoning && !collapseReasoning.classList.contains('show')) {
  1266. new bootstrap.Collapse(collapseReasoning, { show: true });
  1267. }
  1268. // Retry logic function
  1269. async function fetchAndParse(url, retryCount = 0) {
  1270. const MAX_RETRIES = 2;
  1271. let fullText = '';
  1272. let jsonPart = '';
  1273. let hasJsonStarted = false;
  1274. try {
  1275. if (retryCount > 0) {
  1276. logContent.textContent = `\n[System] 解析失败,正在进行第 ${retryCount} 次重试...\n` + logContent.textContent;
  1277. reasoningStatus.textContent = `重试 ${retryCount}...`;
  1278. }
  1279. const response = await fetch('/manager/api/recognize_image', {
  1280. method: 'POST',
  1281. headers: { 'Content-Type': 'application/json' },
  1282. body: JSON.stringify({ image_url: url })
  1283. });
  1284. if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
  1285. const reader = response.body.getReader();
  1286. const decoder = new TextDecoder();
  1287. const separator = "|||JSON_START|||";
  1288. while (true) {
  1289. const { value, done } = await reader.read();
  1290. if (done) break;
  1291. const chunk = decoder.decode(value, { stream: true });
  1292. fullText += chunk;
  1293. // Only update display if not parsing JSON part yet or just started
  1294. if (!hasJsonStarted) {
  1295. const sepIndex = fullText.indexOf(separator);
  1296. if (sepIndex !== -1) {
  1297. hasJsonStarted = true;
  1298. reasoningStatus.textContent = '解析中...';
  1299. reasoningStatus.className = 'badge bg-info ms-2';
  1300. // Split content for display - only once
  1301. const reasoningPart = fullText.substring(0, sepIndex);
  1302. logContent.textContent = reasoningPart;
  1303. if (collapseReasoning) {
  1304. new bootstrap.Collapse(collapseReasoning, { hide: true });
  1305. }
  1306. } else {
  1307. // Update reasoning text
  1308. logContent.textContent = fullText;
  1309. logContent.scrollTop = logContent.scrollHeight;
  1310. }
  1311. }
  1312. }
  1313. // Parsing Logic
  1314. if (hasJsonStarted) {
  1315. const sepIndex = fullText.indexOf(separator);
  1316. jsonPart = fullText.substring(sepIndex + separator.length);
  1317. reasoningStatus.textContent = '完成';
  1318. reasoningStatus.className = 'badge bg-success ms-2';
  1319. } else {
  1320. // Fallback
  1321. jsonPart = fullText;
  1322. }
  1323. // Clean JSON
  1324. // 1. Try finding [...] array
  1325. let start = jsonPart.indexOf('[');
  1326. let end = jsonPart.lastIndexOf(']');
  1327. // 2. If not found, try finding {...} object and wrap it
  1328. let isSingleObject = false;
  1329. if (start === -1 || end === -1 || end <= start) {
  1330. start = jsonPart.indexOf('{');
  1331. end = jsonPart.lastIndexOf('}');
  1332. isSingleObject = true;
  1333. }
  1334. if (start !== -1 && end !== -1 && end > start) {
  1335. jsonPart = jsonPart.substring(start, end + 1);
  1336. } else {
  1337. // Try to extract any JSON-like array/object structure using regex as fallback
  1338. const jsonMatch = jsonPart.match(/(\[.*\]|\{.*\})/s);
  1339. if (jsonMatch) {
  1340. jsonPart = jsonMatch[0];
  1341. if (jsonPart.trim().startsWith('{')) isSingleObject = true;
  1342. } else {
  1343. // No valid JSON structure found
  1344. console.warn("No JSON brackets found in:", jsonPart);
  1345. throw new Error("未找到有效的 JSON 数据结构");
  1346. }
  1347. }
  1348. let data;
  1349. try {
  1350. // Pre-clean: Remove common markdown code block markers if stuck inside
  1351. jsonPart = jsonPart.replace(/^```json\s*/, '').replace(/```$/, '');
  1352. data = JSON.parse(jsonPart);
  1353. } catch (e) {
  1354. // Attempt to fix common JSON errors (e.g. trailing commas, unclosed strings) - simplified
  1355. console.error("JSON parse error. Content:", jsonPart);
  1356. // Force retry on parse error
  1357. throw new Error("JSON 格式解析错误");
  1358. }
  1359. if (isSingleObject && !Array.isArray(data)) {
  1360. data = [data]; // Normalize to array
  1361. } else if (!Array.isArray(data)) {
  1362. data = [data];
  1363. }
  1364. return data;
  1365. } catch (error) {
  1366. if (retryCount < MAX_RETRIES) {
  1367. // Wait 1s and retry
  1368. await new Promise(r => setTimeout(r, 1000));
  1369. return fetchAndParse(url, retryCount + 1);
  1370. }
  1371. throw error;
  1372. }
  1373. }
  1374. try {
  1375. const data = await fetchAndParse(currentImg.url);
  1376. // Use shared processing function
  1377. await processAiData(data);
  1378. // Update local state for persistence during session
  1379. if (images[currentIndex]) {
  1380. images[currentIndex].ai_status = 2;
  1381. images[currentIndex].ai_content = data;
  1382. }
  1383. } catch (error) {
  1384. console.error(error);
  1385. // Append error to log instead of overwriting valid reasoning
  1386. logContent.textContent += `\n\n[Error] ${error.message}`;
  1387. alert('AI 识别过程失败,请重试。\n错误详情: ' + error.message);
  1388. } finally {
  1389. btn.innerHTML = originalContent;
  1390. btn.disabled = false;
  1391. }
  1392. }
  1393. </script>
  1394. {% endblock %}