add_member.html 72 KB

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