| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950 |
- {% extends "layout.html" %}
- {% block title %}{{ '编辑' if member else '录入' }}成员 - 家谱管理系统{% endblock %}
- {% block extra_css %}
- <style>
- .split-container { display: flex; height: calc(100vh - 100px); overflow: hidden; }
- .form-panel { flex: 1.2; padding: 20px; overflow-y: auto; border-right: 1px solid #dee2e6; }
- .image-panel { flex: 0.8; padding: 20px; background: #f8f9fa; display: flex; flex-direction: column; }
- .image-viewer { flex: 1; border: 1px solid #ccc; background: white; overflow: hidden; text-align: center; position: relative; }
- .image-viewer img { max-width: 100%; height: auto; transition: transform 0.2s, filter 0.2s; transform-origin: top left; cursor: grab; }
- .image-viewer img.dragging { cursor: grabbing; }
-
- /* 放大镜样式 */
- .magnifier-glass {
- position: absolute;
- border: 3px solid #000;
- border-radius: 50%;
- cursor: none;
- width: 150px;
- height: 150px;
- box-shadow: 0 0 10px rgba(0,0,0,0.5);
- display: none;
- z-index: 1000;
- background-repeat: no-repeat;
- background-color: white;
- pointer-events: none;
- }
- /* Image Viewer & Dragging */
- .image-viewer {
- flex: 1;
- border: 1px solid #ccc;
- background: #f0f0f0;
- overflow: hidden;
- text-align: center;
- position: relative;
- cursor: grab;
- user-select: none;
- }
- .image-viewer:active {
- cursor: grabbing;
- }
-
- .image-wrapper {
- display: inline-block;
- transition: transform 0.2s ease-out;
- transform-origin: center center;
- position: absolute;
- /* Initial centering will be handled by JS or CSS translate */
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- }
- .image-wrapper img {
- max-width: 100%;
- max-height: 100vh;
- display: block;
- pointer-events: none;
- user-select: none;
- transition: filter 0.2s;
- }
- .image-toolbar {
- background: #e9ecef;
- padding: 5px 10px;
- border-bottom: 1px solid #dee2e6;
- display: flex;
- gap: 10px;
- align-items: center;
- flex-wrap: wrap;
- }
- .image-toolbar .btn-group-xs > .btn, .image-toolbar .btn-sm {
- padding: 0.25rem 0.5rem;
- font-size: 0.875rem;
- }
- .filter-controls { display: flex; align-items: center; gap: 5px; font-size: 0.8rem; }
- .filter-controls input[type=range] { width: 80px; }
- .page-nav { margin-bottom: 10px; display: flex; gap: 10px; align-items: center; }
- .section-title { border-left: 4px solid #0d6efd; padding-left: 10px; margin: 25px 0 15px; font-weight: bold; color: #333; }
- </style>
- {% endblock %}
- {% block content %}
- <div class="split-container">
- <!-- 左侧:录入/编辑表单 -->
- <div class="form-panel">
- <div class="card shadow-sm mb-4">
- <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
- <h5 class="mb-0">{{ '编辑成员信息' if member else '录入新成员' }}</h5>
- <a href="{{ url_for('members') }}" class="btn btn-sm btn-light">返回列表</a>
- </div>
- <div class="card-body">
- <form method="POST">
- <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 '') }}">
- <input type="hidden" name="source_index" value="">
- <div class="section-title">核心信息 (必填)</div>
- <div class="row g-3 mb-4">
- <div class="col-md-6">
- <label class="form-label">姓名(繁体) <span class="text-danger">*</span></label>
- <input type="text" name="name" id="nameInput" class="form-control" required value="{{ member.name if member else '' }}">
- <div id="nameCheckResult" class="mt-2"></div>
- </div>
- <div class="col-md-6">
- <label class="form-label">姓名(简体)</label>
- <input type="text" name="simplified_name" class="form-control" value="{{ member.simplified_name if member else '' }}">
- </div>
- <div class="col-md-6">
- <label class="form-label">性别 <span class="text-danger">*</span></label>
- <select name="sex" class="form-select" required>
- <option value="1" {{ 'selected' if member and member.sex == 1 else '' }}>男</option>
- <option value="2" {{ 'selected' if member and member.sex == 2 else '' }}>女</option>
- </select>
- </div>
- <div class="col-md-6">
- <label class="form-label">出生日期 <span class="text-danger">*</span></label>
- <div class="input-group has-validation">
- {% set birthday_val = member.birthday_date if member and member.birthday_date != '未知' else '' %}
- <input type="date" name="birthday" class="form-control" required value="{{ birthday_val }}" onchange="validateAge()">
- <div class="input-group-text bg-white">
- <input class="form-check-input mt-0" type="checkbox" id="birthdayUnknown" onchange="toggleBirthdayUnknown()" {{ 'checked' if member and member.birthday_date == '未知' else '' }}>
- <label class="form-check-label ms-1 small user-select-none" for="birthdayUnknown">不详</label>
- </div>
- <div class="invalid-feedback" id="ageFeedback"></div>
- </div>
- </div>
- <div class="col-md-8">
- <label class="form-label">世系世代</label>
- <div id="lineage-generations-container" class="d-flex flex-wrap align-items-center gap-2">
- {% if member and member.name_word_generation %}
- {% set generations = member.name_word_generation.split(';') %}
- {% for gen in generations %}
- {% if gen.strip() %}
- <span class="lineage-tag badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 px-3 py-2 rounded-pill d-inline-flex align-items-center" style="font-size: 0.85rem;">
- {{ gen.strip() }}
- <button type="button" class="btn-close ms-2 remove-lineage" style="font-size: 0.55rem; filter: none; opacity: 0.6;" aria-label="删除"></button>
- <input type="hidden" name="lineage_generations[]" value="{{ gen.strip() }}">
- </span>
- {% endif %}
- {% endfor %}
- {% endif %}
- <div id="lineage-input-form" class="d-none">
- <div class="input-group input-group-sm" style="width: 220px;">
- <input type="text" id="lineage-input" class="form-control" placeholder="如:衢州第二十九代" maxlength="20">
- <button type="button" id="confirm-lineage" class="btn btn-success"><i class="bi bi-check-lg"></i></button>
- <button type="button" id="cancel-lineage" class="btn btn-outline-secondary"><i class="bi bi-x-lg"></i></button>
- </div>
- </div>
- <button type="button" id="add-lineage" class="btn btn-outline-primary btn-sm rounded-pill px-3 py-1">
- <i class="bi bi-plus-lg me-1"></i>添加
- </button>
- </div>
- </div>
- <div class="col-md-4">
- <label class="form-label">堂内排行</label>
- <input type="text" name="family_rank" class="form-control" value="{{ member.family_rank if member else '' }}">
- </div>
- </div>
- <div class="section-title">状态信息</div>
- <div class="row g-3 mb-4">
- <div class="col-md-6">
- <label class="form-label">是否过世</label>
- <select name="is_pass_away" class="form-select">
- <option value="0" {{ 'selected' if member and member.is_pass_away == 0 else '' }}>健在</option>
- <option value="1" {{ 'selected' if member and member.is_pass_away == 1 else '' }}>已故</option>
- <option value="2" {{ 'selected' if member and member.is_pass_away == 2 else '' }}>未知</option>
- </select>
- </div>
- <div class="col-md-6">
- <label class="form-label">婚姻状况</label>
- <select name="marital_status" class="form-select">
- <option value="0" {{ 'selected' if member and member.marital_status == 0 else '' }}>未知</option>
- <option value="1" {{ 'selected' if member and member.marital_status == 1 else '' }}>未婚</option>
- <option value="2" {{ 'selected' if member and member.marital_status == 2 else '' }}>已婚</option>
- <option value="3" {{ 'selected' if member and member.marital_status == 3 else '' }}>离异/丧偶</option>
- </select>
- </div>
- </div>
- <div class="section-title">关系录入 (选择关联成员及关系)</div>
- <div class="row g-3 mb-4">
- <div class="col-md-5">
- <label class="form-label">关联成员</label>
- <select name="related_mid" class="form-select">
- <option value="">-- 请选择 --</option>
- {% for m in all_members %}
- <option value="{{ m.id }}" data-birthday="{{ m.birthday }}" {{ 'selected' if current_relation and current_relation.parent_mid == m.id else '' }}>
- {{ m.name }} (ID: {{ m.id }})
- </option>
- {% endfor %}
- </select>
- </div>
- <div class="col-md-4">
- <label class="form-label">关系类型</label>
- <select name="relation_type" class="form-select">
- <option value="">-- 请选择 --</option>
- <option value="1" {{ 'selected' if current_relation and current_relation.relation_type == 1 else '' }}>父子 (关联人为父)</option>
- <option value="2" {{ 'selected' if current_relation and current_relation.relation_type == 2 else '' }}>母子 (关联人为母)</option>
- <option value="10" {{ 'selected' if current_relation and current_relation.relation_type == 10 else '' }}>夫妻</option>
- <option value="11" {{ 'selected' if current_relation and current_relation.relation_type == 11 else '' }}>兄弟</option>
- <option value="12" {{ 'selected' if current_relation and current_relation.relation_type == 12 else '' }}>姐妹</option>
- </select>
- </div>
- <div class="col-md-3">
- <label class="form-label">子类型</label>
- <select name="sub_relation_type" class="form-select">
- <option value="0" {{ 'selected' if current_relation and current_relation.sub_relation_type == 0 else '' }}>亲生/正妻</option>
- <option value="1" {{ 'selected' if current_relation and current_relation.sub_relation_type == 1 else '' }}>养父</option>
- <option value="2" {{ 'selected' if current_relation and current_relation.sub_relation_type == 2 else '' }}>过继</option>
- <option value="10" {{ 'selected' if current_relation and current_relation.sub_relation_type == 10 else '' }}>妾</option>
- <option value="11" {{ 'selected' if current_relation and current_relation.sub_relation_type == 11 else '' }}>外室</option>
- </select>
- </div>
- </div>
-
- <div class="section-title">人员备注</div>
- <div class="row g-3 mb-4">
- <div class="col-md-12">
- <textarea name="notes" class="form-control" rows="2">{{ member.notes if member else '' }}</textarea>
- </div>
- </div>
-
- <!-- 悬浮的保存按钮,始终保持在一屏内或跟随页面底部 -->
- <div class="d-grid gap-2 mb-4 sticky-bottom bg-white py-2 border-top" style="z-index: 1020;">
- <button type="submit" class="btn btn-success btn-lg">
- <i class="bi bi-check-circle me-1"></i> {{ '保存修改' if member else '确认录入' }}
- </button>
- </div>
- <!-- 折叠的其他信息区 -->
- <div class="accordion" id="accordionExtraInfo">
- <div class="accordion-item border-0">
- <h2 class="accordion-header" id="headingExtra">
- <button class="accordion-button collapsed bg-light text-secondary rounded shadow-sm border" type="button" data-bs-toggle="collapse" data-bs-target="#collapseExtra" aria-expanded="false" aria-controls="collapseExtra">
- <i class="bi bi-three-dots me-2"></i> 展开更多其他信息(谱系详情、联络、履历等)
- </button>
- </h2>
- <div id="collapseExtra" class="accordion-collapse collapse" aria-labelledby="headingExtra" data-bs-parent="#accordionExtraInfo">
- <div class="accordion-body px-0 pt-3 pb-0">
-
- <div class="section-title mt-0">谱系详情</div>
- <div class="row g-3 mb-4">
- <div class="col-md-4">
- <label class="form-label">曾用名</label>
- <input type="text" name="former_name" class="form-control" value="{{ member.former_name if member else '' }}">
- </div>
- <div class="col-md-4">
- <label class="form-label">幼名/乳名</label>
- <input type="text" name="childhood_name" class="form-control" value="{{ member.childhood_name if member else '' }}">
- </div>
- <div class="col-md-4">
- <label class="form-label">字辈</label>
- <input type="text" name="name_word" class="form-control" value="{{ member.name_word if member else '' }}">
- </div>
- <div class="col-md-6">
- <label class="form-label">名号/封号</label>
- <input type="text" name="name_title" class="form-control" value="{{ member.name_title if member else '' }}">
- </div>
- <div class="col-md-6">
- <label class="form-label">分房/堂号</label>
- <input type="text" name="branch_family_hall" class="form-control" value="{{ member.branch_family_hall if member else '' }}">
- </div>
- <div class="col-md-6">
- <label class="form-label">聚居地</label>
- <input type="text" name="cluster_place" class="form-control" value="{{ member.cluster_place if member else '' }}">
- </div>
- </div>
- <div class="section-title">联络信息</div>
- <div class="row g-3 mb-4">
- <div class="col-md-4">
- <label class="form-label">民族</label>
- <input type="text" name="nation" class="form-control" value="{{ member.nation if member else '' }}">
- </div>
- <div class="col-md-4">
- <label class="form-label">手机号</label>
- <input type="text" name="phone" class="form-control" value="{{ member.phone if member else '' }}">
- </div>
- <div class="col-md-4">
- <label class="form-label">微信号</label>
- <input type="text" name="wechat_account" class="form-control" value="{{ member.wechat_account if member else '' }}">
- </div>
- <div class="col-md-12">
- <label class="form-label">现居住址</label>
- <input type="text" name="residential_address" class="form-control" value="{{ member.residential_address if member else '' }}">
- </div>
- </div>
- <div class="section-title">个人履历</div>
- <div class="row g-3 mb-2">
- <div class="col-md-6">
- <label class="form-label">职业</label>
- <textarea name="occupation" class="form-control" rows="2">{{ member.occupation if member else '' }}</textarea>
- </div>
- <div class="col-md-6">
- <label class="form-label">教育背景</label>
- <textarea name="educational" class="form-control" rows="2">{{ member.educational if member else '' }}</textarea>
- </div>
- <div class="col-md-12">
- <label class="form-label">标签</label>
- <input type="text" name="tags" class="form-control" placeholder="例如:抗战老兵, 教师 (用逗号分隔)" value="{{ member.tags if member else '' }}">
- </div>
- <div class="col-md-12">
- <label class="form-label">个人成就</label>
- <textarea name="personal_achievements" class="form-control" rows="3">{{ member.personal_achievements if member else '' }}</textarea>
- </div>
- </div>
-
- </div>
- </div>
- </div>
- </div>
- </form>
- </div>
- </div>
- </div>
-
- <!-- AI 推理日志及结果面板 -->
- <div id="aiLogPanel" class="position-fixed bottom-0 end-0 p-3 bg-dark text-white shadow"
- style="display: none; width: 450px; max-height: 85vh; border-radius: 8px 0 0 0; z-index: 1050; opacity: 0.95; overflow-y: auto;">
-
- <!-- 吸顶头部与当前选中详情 -->
- <div class="sticky-top bg-dark pb-2" style="z-index: 1060; margin-top: -1rem; padding-top: 1rem; border-bottom: 1px solid #444;">
- <div class="d-flex justify-content-between align-items-center mb-2 pb-1">
- <span class="fw-bold"><i class="bi bi-robot"></i> AI 识别助手</span>
- <button class="btn btn-sm btn-outline-light py-0" onclick="closeAiLog()">×</button>
- </div>
-
- <!-- 当前选中详情 -->
- <div id="aiCurrentDetail" class="mt-2 p-2 bg-secondary bg-opacity-25 rounded border border-info shadow-sm" style="display:none; max-height: 250px; overflow-y: auto;">
- <div class="d-flex justify-content-between align-items-center mb-2 border-bottom border-secondary pb-1">
- <strong class="text-info"><i class="bi bi-info-circle"></i> 当前填充详情</strong>
- <button class="btn btn-sm btn-link text-muted py-0 text-decoration-none" onclick="document.getElementById('aiCurrentDetail').style.display='none'">×</button>
- </div>
- <div id="aiDetailContent" class="small text-light" style="word-break: break-all;"></div>
- </div>
- </div>
-
- <!-- 推理过程 -->
- <div class="mb-3 mt-3">
- <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">
- <i class="bi bi-cpu me-1"></i> 推理过程 <span class="badge bg-secondary ms-2" id="reasoningStatus">进行中...</span>
- </button>
- <div class="collapse show" id="collapseReasoning">
- <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>
- </div>
- </div>
- <!-- 识别结果列表 -->
- <div id="aiResultSection" style="display: none;">
- <div class="d-flex justify-content-between align-items-center mb-2">
- <h6 class="mb-0 text-info"><i class="bi bi-check-circle"></i> 识别结果 (<span id="resultCount">0</span>)</h6>
- <span class="small text-muted">点击下方条目填充</span>
- </div>
- <div id="aiResultList" class="d-flex flex-column gap-2">
- <!-- 结果项将动态插入 -->
- </div>
- </div>
- </div>
- <!-- 右侧:图片参考 -->
- <div class="image-panel">
- <div class="page-nav">
- <label class="fw-bold">扫描件参考:</label>
- <button id="aiBtn" onclick="recognizeImage()" class="btn btn-sm btn-info text-white ms-2 me-2">
- <i class="bi bi-magic"></i> AI 识别
- </button>
- <input type="number" id="pageInput" class="form-control form-control-sm" style="width: 70px;" placeholder="页码">
- <button onclick="gotoPage()" class="btn btn-sm btn-primary">跳转</button>
- <div class="ms-auto small text-muted">
- 当前: <span id="currentPage">1</span> / <span id="totalPages">{{ images|length }}</span>
- </div>
- </div>
- <div class="mb-2 small text-muted" id="imageMetadata" style="display: none;">
- <span class="me-2"><i class="bi bi-journal-text"></i> 版本名称: <span id="metaVersion">-</span></span>
- <span class="me-2"><i class="bi bi-archive"></i> 版本来源: <span id="metaSource">-</span></span>
- <span><i class="bi bi-person"></i> 提供人: <span id="metaPerson">-</span></span>
- </div>
- <div class="image-toolbar rounded-top">
- <div class="btn-group btn-group-sm">
- <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(-90)" title="左旋90°"><i class="bi bi-arrow-counterclockwise"></i></button>
- <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(90)" title="右旋90°"><i class="bi bi-arrow-clockwise"></i></button>
- </div>
- <div class="filter-controls border-start border-end px-2 mx-1">
- <i class="bi bi-brightness-high" title="亮度"></i>
- <input type="range" min="50" max="150" value="100" oninput="updateImageFilter()" id="brightnessRange">
- <i class="bi bi-circle-half ms-2" title="对比度"></i>
- <input type="range" min="50" max="200" value="100" oninput="updateImageFilter()" id="contrastRange">
- <button class="btn btn-link btn-sm text-decoration-none py-0" onclick="resetFilters()">重置</button>
- </div>
- <div class="form-check form-switch ms-auto mb-0" title="开启后鼠标悬停图片可局部放大">
- <input class="form-check-input" type="checkbox" id="magnifierSwitch">
- <label class="form-check-label small" for="magnifierSwitch">🔍 放大镜</label>
- </div>
- </div>
- <div class="image-viewer shadow-inner" id="viewer">
- <div id="magnifier" class="magnifier-glass"></div>
- <div id="imageWrapper" class="image-wrapper">
- {% if images %}
- <img id="refImage" src="{{ images[0].oss_url }}" alt="家谱图片" draggable="false">
- {% else %}
- <div class="mt-5 text-muted">
- <i class="bi bi-image fs-1 d-block mb-2"></i>
- 暂无上传的家谱图片
- </div>
- {% endif %}
- </div>
- </div>
- <div class="mt-2 d-flex justify-content-between">
- <button onclick="prevImage()" class="btn btn-sm btn-outline-secondary">上一张</button>
- <button onclick="nextImage()" class="btn btn-sm btn-outline-secondary">下一张</button>
- </div>
- </div>
- </div>
- {% endblock %}
- {% block extra_js %}
- <script>
- let tomSelectInstance = null;
- function toggleBirthdayUnknown() {
- const cb = document.getElementById('birthdayUnknown');
- const input = document.querySelector('input[name="birthday"]');
- if (!cb || !input) return;
-
- if (cb.checked) {
- input.value = '';
- input.disabled = true;
- input.required = false;
- input.classList.remove('is-invalid');
- const fb = document.getElementById('ageFeedback');
- if(fb) fb.textContent = '';
- } else {
- input.disabled = false;
- input.required = true;
- }
- }
- function validateAge() {
- const cb = document.getElementById('birthdayUnknown');
- if (cb && cb.checked) return;
- const birthdayInput = document.querySelector('input[name="birthday"]');
- const relatedSelect = document.querySelector('select[name="related_mid"]');
- const relationType = document.querySelector('select[name="relation_type"]');
- const feedback = document.getElementById('ageFeedback');
-
- if (!birthdayInput.value || !relatedSelect.value) {
- birthdayInput.classList.remove('is-invalid');
- return;
- }
-
- // Only check for Parent-Child relations (1: Father, 2: Mother)
- if (relationType.value !== '1' && relationType.value !== '2') return;
-
- // We need the parent's birthday. This is tricky as we only have the ID.
- // Option 1: Store parent birthdays in the select option dataset (easiest)
- // Option 2: Async fetch.
-
- const selectedOption = relatedSelect.options[relatedSelect.selectedIndex];
- const parentBirthdayTs = parseInt(selectedOption.dataset.birthday || '0');
-
- if (parentBirthdayTs > 0) {
- const childBirthday = new Date(birthdayInput.value).getTime() / 1000;
- if (childBirthday < parentBirthdayTs) {
- birthdayInput.classList.add('is-invalid');
- feedback.textContent = '警告:子女出生日期早于父母,请核对!';
- } else if (childBirthday - parentBirthdayTs < 12 * 365 * 24 * 3600) {
- // Warning if age gap < 12 years
- birthdayInput.classList.add('is-invalid');
- feedback.textContent = '警告:父母与子女年龄差小于12岁,请核对!';
- } else {
- birthdayInput.classList.remove('is-invalid');
- }
- }
- }
- // Call validation when relation changes too
- document.addEventListener('DOMContentLoaded', () => {
- const relatedSelect = document.querySelector('select[name="related_mid"]');
- if (relatedSelect) {
- relatedSelect.addEventListener('change', validateAge);
- if (typeof TomSelect !== 'undefined') {
- tomSelectInstance = new TomSelect(relatedSelect, {
- create: false,
- sortField: null,
- searchField: ['text'],
- render: {
- no_results: function(data, escape) {
- return '<div class="no-results">未找到匹配项</div>';
- }
- }
- });
- }
- }
-
- // Initialize birthday unknown state
- toggleBirthdayUnknown();
- });
- const images = [
- {% for img in images %}
- {
- id: {{ img.id }},
- url: "{{ img.oss_url }}",
- page: {{ img.page_number or 0 }},
- ai_status: {{ img.ai_status or 0 }},
- ai_content: {{ img.ai_content | tojson | safe if img.ai_content else 'null' }},
- genealogy_version: "{{ img.genealogy_version or '' }}",
- genealogy_source: "{{ img.genealogy_source or '' }}",
- upload_person: "{{ img.upload_person or '' }}"
- },
- {% endfor %}
- ];
- let currentIndex = 0;
- let currentParsedPeople = [];
-
- // Image State
- let imgRotation = 0;
- let imgBrightness = 100;
- let imgContrast = 100;
-
- // Dragging State
- let isDragging = false;
- let hasDragged = false;
- let startX = 0, startY = 0;
- let currentX = 0, currentY = 0; // Relative to center (offsets)
- // Zoom State
- let isZoomedIn = false;
- const ZOOM_LEVEL = 2.0;
- // Magnifier Logic
- const viewer = document.getElementById('viewer');
- const magnifier = document.getElementById('magnifier');
- const magnifierSwitch = document.getElementById('magnifierSwitch');
- const imageWrapper = document.getElementById('imageWrapper');
-
- // Initialize Dragging and Zooming
- if (imageWrapper) {
- // Center initial position
- imageWrapper.style.left = '50%';
- imageWrapper.style.top = '50%';
-
- viewer.style.cursor = 'zoom-in';
- viewer.addEventListener('mousedown', (e) => {
- if (e.target.closest('.image-toolbar') || e.target.closest('.magnifier-glass')) return;
- isDragging = true;
- hasDragged = false;
- startX = e.clientX;
- startY = e.clientY;
-
- viewer.style.cursor = 'grabbing';
- e.preventDefault(); // Prevent text selection
- });
-
- window.addEventListener('mousemove', (e) => {
- if (!isDragging) return;
-
- const dx = e.clientX - startX;
- const dy = e.clientY - startY;
-
- // Threshold to consider it a drag
- if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
- hasDragged = true;
- }
- currentX += dx;
- currentY += dy;
-
- startX = e.clientX;
- startY = e.clientY;
-
- updateImageTransform();
- });
-
- window.addEventListener('mouseup', (e) => {
- if (isDragging) {
- isDragging = false;
- viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
-
- // If it was a click (not a drag) and clicked inside the viewer
- if (!hasDragged && viewer.contains(e.target)) {
- toggleZoom();
- }
- }
- });
- }
- function toggleZoom() {
- isZoomedIn = !isZoomedIn;
- if (!isZoomedIn) {
- // Reset position when zooming out to center
- currentX = 0;
- currentY = 0;
- }
- updateImageTransform();
- // Update cursor immediately
- viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
- }
-
- viewer.addEventListener('mousemove', function(e) {
- if (!magnifierSwitch.checked) {
- magnifier.style.display = 'none';
- return;
- }
-
- if (isDragging) {
- magnifier.style.display = 'none';
- return;
- }
-
- const img = document.getElementById('refImage');
- if (!img) return;
- // Calculate position relative to the image
- const rect = img.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const y = e.clientY - rect.top;
-
- // Only show if inside image rect (approximate for rotated)
- if (x < 0 || x > rect.width || y < 0 || y > rect.height) {
- magnifier.style.display = 'none';
- return;
- }
- magnifier.style.display = 'block';
-
- // Position the glass near mouse
- const glassOffset = 20;
- const viewerRect = viewer.getBoundingClientRect();
- magnifier.style.left = (e.clientX - viewerRect.left + glassOffset) + 'px';
- magnifier.style.top = (e.clientY - viewerRect.top + glassOffset) + 'px';
-
- // Background logic (Zoom 2x)
- const zoom = 2.5;
- magnifier.style.backgroundImage = `url('${img.src}')`;
- magnifier.style.backgroundSize = `${rect.width * zoom}px ${rect.height * zoom}px`;
-
- // Simple version (imperfect for rotation)
- magnifier.style.backgroundPosition = `-${x * zoom - 75}px -${y * zoom - 75}px`;
- });
- // Image Manipulation
- function rotateImage(deg) {
- imgRotation = (imgRotation + deg) % 360;
- updateImageTransform();
- }
-
- function updateImageFilter() {
- imgBrightness = document.getElementById('brightnessRange').value;
- imgContrast = document.getElementById('contrastRange').value;
- applyImageFilters();
- }
-
- function resetFilters() {
- imgRotation = 0;
- imgBrightness = 100;
- imgContrast = 100;
- currentX = 0;
- currentY = 0;
- isZoomedIn = false;
-
- document.getElementById('brightnessRange').value = 100;
- document.getElementById('contrastRange').value = 100;
-
- updateImageTransform();
- applyImageFilters();
- }
-
- function updateImageTransform() {
- const wrapper = document.getElementById('imageWrapper');
- if (wrapper) {
- const scale = isZoomedIn ? ZOOM_LEVEL : 1;
- wrapper.style.transform = `translate(calc(-50% + ${currentX}px), calc(-50% + ${currentY}px)) rotate(${imgRotation}deg) scale(${scale})`;
-
- // Adjust cursor based on state
- if (!isDragging) {
- viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
- }
- }
- }
-
- function applyImageFilters() {
- const img = document.getElementById('refImage');
- if (img) {
- img.style.filter = `brightness(${imgBrightness}%) contrast(${imgContrast}%)`;
- }
- }
-
- // Reuse applyImageStyles as alias for compatibility if called elsewhere
- function applyImageStyles() {
- updateImageTransform();
- applyImageFilters();
- }
- const fieldMapping = {
- name: '姓名(繁体)',
- simplified_name: '姓名(简体)',
- sex: '性别',
- birthday: '出生日期',
- father_name: '父亲姓名',
- spouse_name: '配偶姓名',
- generation: '堂内排行(代数)',
- name_word: '字辈',
- education: '学历/功名',
- title: '官职/称号',
- death_date: '逝世日期',
- note: '备注'
- };
- // --- Keyboard Shortcuts ---
- document.addEventListener('keydown', (e) => {
- // Ctrl/Cmd + Enter: Save
- if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
- e.preventDefault();
- const submitBtn = document.querySelector('form button[type="submit"]');
- if (submitBtn && !submitBtn.disabled) {
- submitBtn.click(); // Trigger form submit listener
- }
- }
-
- // Ctrl/Cmd + Right Arrow: Next Image
- if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowRight') {
- e.preventDefault();
- nextImage();
- }
-
- // Ctrl/Cmd + Left Arrow: Prev Image
- if ((e.ctrlKey || e.metaKey) && e.key === 'ArrowLeft') {
- e.preventDefault();
- prevImage();
- }
-
- // Alt + 1: Auto Fill First person in list
- if (e.altKey && e.key === '1') {
- e.preventDefault();
- // Try to find the first "fill" button that is not disabled/success
- const firstBtn = document.querySelector('button[id^="btn-fill-"]:not(.btn-success)');
- if (firstBtn) firstBtn.click();
- }
- });
- // --- AJAX Form Submission ---
- document.addEventListener('DOMContentLoaded', () => {
- const form = document.querySelector('form');
- form.addEventListener('submit', async (e) => {
- e.preventDefault();
-
- // Collect form data
- const formData = new FormData(form);
-
- // Visual feedback on button
- const submitBtn = form.querySelector('button[type="submit"]');
- const originalBtnHtml = submitBtn.innerHTML;
- submitBtn.disabled = true;
- submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 保存中...';
-
- try {
- // Use form.action to support both add and edit URLs
- const targetUrl = form.action || window.location.href;
- const response = await fetch(targetUrl, {
- method: 'POST',
- body: formData,
- headers: {
- 'X-Requested-With': 'XMLHttpRequest'
- }
- });
-
- const result = await response.json();
-
- if (result.success) {
- // Success!
- // 1. Show a toast or small alert
- const toast = document.createElement('div');
- toast.className = 'position-fixed bottom-0 start-50 translate-middle-x mb-4 p-3 bg-success text-white rounded shadow';
- toast.style.zIndex = '2000';
- toast.innerHTML = `<i class="bi bi-check-circle me-2"></i> ${result.message}`;
- document.body.appendChild(toast);
- setTimeout(() => toast.remove(), 3000);
-
- // 2. Mark the AI list item as "Saved" if applicable
- if (window.lastFilledIndex !== undefined) {
- const btn = document.getElementById(`btn-fill-${window.lastFilledIndex}`);
- if (btn) {
- btn.className = 'btn btn-sm btn-success text-white ms-2 disabled';
- btn.innerHTML = '<i class="bi bi-check-lg"></i> 已录入';
- btn.onclick = null;
- }
-
- // Update local data state so it persists if we switch images/filters
- if (currentParsedPeople[window.lastFilledIndex]) {
- currentParsedPeople[window.lastFilledIndex].is_imported = true;
- currentParsedPeople[window.lastFilledIndex].imported_member_id = result.member_id;
-
- // Sync back to images array to persist across image switching
- if (images[currentIndex]) {
- images[currentIndex].ai_content = currentParsedPeople;
- }
- }
- }
-
- // 3. Clear form (reset to defaults) or keep some fields?
- // Usually for genealogy, Surname/Generation might be same, but let's clear for safety
- // Resetting form but keeping "related_mid" might be useful for siblings?
- // For now, simple reset.
-
- // --- Update Local Matches before resetting form ---
- // If we just saved a person, check if this person is the father/spouse of anyone else in the list
- // and update their matches so 'fillForm' will work for them.
-
- const savedName = formData.get('name'); // Traditional (Raw)
- const savedSimplifiedName = formData.get('simplified_name'); // Simplified (Cleaned)
- const savedId = result.member_id;
- const savedSex = formData.get('sex'); // 1: Male, 2: Female
-
- if (savedId) {
- currentParsedPeople.forEach(p => {
- if (!p.matches) p.matches = {};
-
- // Check Father Match
- // Try matching against Simplified Name (p.father_name is Simplified Cleaned)
- // Or fallback to savedName if p.father_name happened to be Traditional (rare but possible)
- if (p.father_name && (p.father_name === savedSimplifiedName || p.father_name === savedName)) {
- // Assume simple match logic here (usually father is male)
- if (savedSex === '1') {
- if (!p.matches.father) p.matches.father = [];
- // Add to matches if not exists
- if (!p.matches.father.find(m => m.id === savedId)) {
- p.matches.father.push({ id: savedId, name: savedName, sex: 1 }); // Mock DB object
- }
- }
- }
-
- // Check Spouse Match
- if (p.spouse_name && (p.spouse_name === savedSimplifiedName || p.spouse_name === savedName)) {
- // Spouse logic...
- if (!p.matches.spouse) p.matches.spouse = [];
- if (!p.matches.spouse.find(m => m.id === savedId)) {
- p.matches.spouse.push({ id: savedId, name: savedName, sex: parseInt(savedSex) });
- }
- }
- });
-
- // Also, we need to add this new member to the <select> options for future manual selection!
- // This is tricky because the select is rendered by Jinja2.
- // We can append an option via JS.
- const relatedSelect = document.querySelector('select[name="related_mid"]');
- if (relatedSelect) {
- const newOption = document.createElement('option');
- newOption.value = savedId;
- newOption.textContent = `${savedName} (ID: ${savedId})`;
- // Add birthday data if available for validation
- newOption.dataset.birthday = new Date(formData.get('birthday')).getTime() / 1000;
- relatedSelect.add(newOption); // Add to end
- }
- }
- // --- End Local Match Update ---
- form.reset();
- // Clear lineage generation tags
- document.querySelectorAll('#lineage-generations-container .lineage-tag').forEach(t => t.remove());
- const lgInput = document.getElementById('lineage-input-form');
- if (lgInput) lgInput.classList.add('d-none');
- const lgAdd = document.getElementById('add-lineage');
- if (lgAdd) lgAdd.classList.remove('d-none');
- form.querySelector('[name="personal_achievements"]').value = '';
- form.querySelector('[name="notes"]').value = '';
- form.querySelector('[name="tags"]').value = '';
- form.querySelector('[name="family_rank"]').value = '';
-
- // Close detail panel
- document.getElementById('aiCurrentDetail').style.display = 'none';
- // 4. Auto-Next Logic
- // Find the next available person in the list to fill
- if (window.lastFilledIndex !== undefined) {
- const nextIndex = window.lastFilledIndex + 1;
- if (currentParsedPeople[nextIndex]) {
- // Automatically fill the next one!
- fillForm(nextIndex);
-
- // Scroll list to show the new active item if needed
- const btn = document.getElementById(`btn-fill-${nextIndex}`);
- if(btn) btn.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
- }
- } else {
- alert('保存失败: ' + result.message);
- }
- } catch (error) {
- console.error('Error submitting form:', error);
- alert('网络或服务器错误,请稍后重试');
- } finally {
- submitBtn.disabled = false;
- submitBtn.innerHTML = originalBtnHtml;
- }
- });
- });
- // --- End AJAX Form Submission ---
- function updateDisplay() {
- if (images.length > 0) {
- const img = images[currentIndex];
- document.getElementById('refImage').src = img.url;
- document.getElementById('currentPage').innerText = currentIndex + 1;
-
- // Update metadata display
- const metaContainer = document.getElementById('imageMetadata');
- if (img.genealogy_version || img.genealogy_source || img.upload_person) {
- metaContainer.style.display = 'block';
- document.getElementById('metaVersion').innerText = img.genealogy_version || '未提供';
- document.getElementById('metaSource').innerText = img.genealogy_source || '未提供';
- document.getElementById('metaPerson').innerText = img.upload_person || '未提供';
- } else {
- metaContainer.style.display = 'none';
- }
-
- // Reset image state on switch
- resetFilters();
-
- // AI Button Logic
- const aiBtn = document.getElementById('aiBtn');
- const aiPanel = document.getElementById('aiLogPanel');
- const resultList = document.getElementById('aiResultList');
- const resultCount = document.getElementById('resultCount');
-
- // Hide panel when switching images to avoid confusion
- if (aiPanel) aiPanel.style.display = 'none';
- // Clear current data
- currentParsedPeople = [];
- if (resultCount) resultCount.innerText = '0';
- if (resultList) resultList.innerHTML = '';
- if (img.ai_status === 2 && img.ai_content) {
- // Determine content
- let content = img.ai_content;
- // Parse if string (it might be a string if double encoded or stored as JSON string in DB)
- if (typeof content === 'string') {
- try { content = JSON.parse(content); } catch(e) { content = []; }
- }
- if (!Array.isArray(content) && content) content = [content];
-
- if (content && content.length > 0) {
- // Update Button to "View Results"
- aiBtn.innerHTML = '<i class="bi bi-list-check"></i> 查看解析结果';
- aiBtn.className = 'btn btn-sm btn-success text-white ms-2 me-2';
- aiBtn.onclick = function() {
- // Show panel with loading
- if (aiPanel) aiPanel.style.display = 'block';
- if (resultList) resultList.innerHTML = '<div class="text-center p-3"><div class="spinner-border text-primary" role="status"></div></div>';
-
- // Process (small delay to allow UI update)
- setTimeout(() => processAiData(content), 10);
- };
- return; // Done
- }
- }
-
- // Default: Reset to "AI Recognition"
- aiBtn.innerHTML = '<i class="bi bi-magic"></i> AI 识别';
- aiBtn.className = 'btn btn-sm btn-info text-white ms-2 me-2';
- aiBtn.onclick = recognizeImage;
- }
- }
- function nextImage() {
- if (currentIndex < images.length - 1) {
- currentIndex++;
- updateDisplay();
- }
- }
- function prevImage() {
- if (currentIndex > 0) {
- currentIndex--;
- updateDisplay();
- }
- }
- function gotoPage() {
- const val = document.getElementById('pageInput').value;
- if (!val) return;
- const page = parseInt(val);
- const index = images.findIndex(img => img.page === page);
- if (index !== -1) {
- currentIndex = index;
- updateDisplay();
- } else {
- alert('未找到该页码对应的图片');
- }
- }
- function closeAiLog() {
- document.getElementById('aiLogPanel').style.display = 'none';
- }
- function toggleAiPanel() {
- const panel = document.getElementById('aiLogPanel');
- if (panel.style.display === 'none') {
- panel.style.display = 'block';
- } else {
- panel.style.display = 'none';
- }
- }
- function updateAiButtonState(hasResults) {
- const btn = document.getElementById('aiBtn');
- if (!btn) return;
- if (hasResults) {
- btn.innerHTML = '<i class="bi bi-list-check"></i> 查看识别结果';
- btn.onclick = toggleAiPanel;
- btn.classList.remove('btn-info');
- btn.classList.add('btn-success');
- } else {
- // Revert state if needed (usually on new image load if we clear data)
- btn.innerHTML = '<i class="bi bi-magic"></i> AI 识别';
- btn.onclick = recognizeImage;
- btn.classList.remove('btn-success');
- btn.classList.add('btn-info');
- }
- }
- function fillForm(index) {
- window.lastFilledIndex = index;
- const person = currentParsedPeople[index];
- if (!person) return;
-
- const form = document.querySelector('form');
- form.reset(); // Clear previous data first
- if (tomSelectInstance) {
- tomSelectInstance.clear();
- }
-
- // Set Source Index
- const sourceIndexInput = form.querySelector('[name="source_index"]');
- if (sourceIndexInput) sourceIndexInput.value = index;
- // 1. 姓名
- if (person.name) {
- const nameInput = form.querySelector('[name="name"]');
- nameInput.value = person.name;
- nameInput.dispatchEvent(new Event('input')); // 触发重名检测
- }
- if (person.simplified_name) {
- const snInput = form.querySelector('[name="simplified_name"]');
- if (snInput) snInput.value = person.simplified_name;
- } else {
- // Fallback: if no simplified_name explicitly, generate it
- if (person.name) {
- const snInput = form.querySelector('[name="simplified_name"]');
- if (snInput) snInput.value = cleanName(person.name);
- }
- }
-
- // 2. 性别
- if (person.sex) {
- const sexSelect = form.querySelector('[name="sex"]');
- if (person.sex.includes('女')) sexSelect.value = '2';
- else if (person.sex.includes('男')) sexSelect.value = '1';
- }
-
- // 3. 生日 & 自动推断过世
- // Reset unknown toggle first
- const birthdayUnknownCb = document.getElementById('birthdayUnknown');
- if (birthdayUnknownCb) {
- birthdayUnknownCb.checked = false;
- toggleBirthdayUnknown();
- }
- let isDeceased = false;
- let isDeceasedUnknown = true;
-
- if (person.birthday) {
- let dateVal = person.birthday;
-
- // Handle partial dates like "1890年" or "1890年?月?日"
- const partialYearMatch = dateVal.match(/^(\d{4})[^\d]*$/) || dateVal.match(/(\d{4})年\s*[??Xxx]\s*月/i);
- if (partialYearMatch) {
- dateVal = `${partialYearMatch[1]}-01-01`;
- } else {
- // 尝试标准化完整日期
- const dateMatch = dateVal.match(/(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})/);
- if (dateMatch) {
- const y = dateMatch[1];
- const m = dateMatch[2].padStart(2, '0');
- const d = dateMatch[3].padStart(2, '0');
- dateVal = `${y}-${m}-${d}`;
- }
- }
-
- // 只有当日期格式正确时才填充
- if (/^\d{4}-\d{2}-\d{2}$/.test(dateVal)) {
- form.querySelector('[name="birthday"]').value = dateVal;
-
- // Auto "Is Deceased" Logic (e.g. older than 100 years from now)
- const birthYear = parseInt(dateVal.substring(0, 4));
- const currentYear = new Date().getFullYear();
- if (currentYear - birthYear > 100) {
- isDeceased = true;
- }
- isDeceasedUnknown = false;
- } else {
- // Parse failed, set to unknown
- if (birthdayUnknownCb) {
- birthdayUnknownCb.checked = true;
- toggleBirthdayUnknown();
- }
- }
- } else {
- // No birthday found, automatically check unknown
- if (birthdayUnknownCb) {
- birthdayUnknownCb.checked = true;
- toggleBirthdayUnknown();
- }
- }
-
- // 当自己年龄不详时,通过父母年龄推断是否在世
- if (isDeceasedUnknown && person.matches && person.matches.father && person.matches.father.length > 0) {
- const father = person.matches.father[0];
- if (father.birthday) {
- const fatherBirthYear = new Date(father.birthday * 1000).getFullYear();
- const currentYear = new Date().getFullYear();
- // 假设如果父亲是120年前出生的,子女大概率也已超过100岁
- if (currentYear - fatherBirthYear > 120) {
- isDeceased = true;
- isDeceasedUnknown = false;
- }
- }
- }
-
- // 已故状态
- const passAwaySelect = form.querySelector('[name="is_pass_away"]');
- if (passAwaySelect) {
- // "殁", "葬", "卒" in raw text usually means deceased. If AI extracted death_date, also true.
- if (isDeceased || person.death_date) {
- passAwaySelect.value = '1'; // 已故
- } else if (isDeceasedUnknown) {
- passAwaySelect.value = '2'; // 未知
- } else {
- passAwaySelect.value = '0'; // 默认健在,除非有证据
- }
- }
- // 4. 婚姻状况
- const maritalSelect = form.querySelector('[name="marital_status"]');
- if (maritalSelect) {
- if (person.spouse_name) {
- maritalSelect.value = '2'; // 已婚
- } else {
- maritalSelect.value = '0'; // 未知
- }
- }
- // 4. 代数 -> 堂内排行
- if (person.generation) {
- const genMatch = person.generation.match(/\d+/);
- // 这里将 AI 解析的 'generation' 填入 'family_rank' (堂内排行)
- // 'name_word_generation' (世系世代) 保持为空
- form.querySelector('[name="family_rank"]').value = person.generation;
- }
- // 4.5 字辈 (name_word)
- let zibei = person.name_word;
- if (!zibei && person.name) {
- // Heuristic: If name starts with "留" and is 3 chars long (e.g. 留学勤), Zibei is index 1.
- // If name starts with "留" and is > 3 chars, we can't be sure, but index 1 is a good guess for generation char.
- // "留学公" -> "留" + "学" + "公". Zibei "学".
- // "留学勤" -> "留" + "学" + "勤". Zibei "学".
-
- // Let's use a safe heuristic: if name starts with '留' and length >= 3
- if (person.name.startsWith('留') && person.name.length >= 3) {
- zibei = person.name.charAt(1);
- }
- }
- if (zibei) {
- form.querySelector('[name="name_word"]').value = zibei;
- person.name_word = zibei; // Update data object for display
- }
- // 5. 其他信息
- if (person.education) form.querySelector('[name="educational"]').value = person.education;
- if (person.title) form.querySelector('[name="occupation"]').value = person.title;
-
- // 个人成就/备注字段追加信息
- let extraInfo = [];
- if (person.father_name) extraInfo.push(`父亲: ${person.father_name}`);
- if (person.spouse_name) extraInfo.push(`配偶: ${person.spouse_name}`);
-
- // 将亲属关系存入 'notes' (人员备注) 字段
- const notesField = form.querySelector('[name="notes"]');
- const currentNotes = notesField.value;
- const newInfo = extraInfo.join('; ');
-
- if (newInfo && !currentNotes.includes(newInfo)) {
- notesField.value = currentNotes ? (currentNotes + '\n' + newInfo) : newInfo;
- }
-
- // 确保无论如何都触发一遍自动匹配事件
- notesField.dispatchEvent(new Event('input', {bubbles: true}));
- if (window.checkSpouseInNotes) {
- window.checkSpouseInNotes();
- }
-
- // --- Auto-Linking Logic ---
- if (person.matches) {
- // Priority: Father > Spouse (Configurable?)
- // For now, if father matches, select father.
- if (person.matches.father && person.matches.father.length > 0) {
- // Pick the first one for now (could show UI to choose if multiple)
- const father = person.matches.father[0];
- const relSelect = form.querySelector('[name="related_mid"]');
- const relTypeSelect = form.querySelector('[name="relation_type"]');
-
- if (relSelect && relTypeSelect) {
- if (typeof tomSelectInstance !== 'undefined' && tomSelectInstance) {
- tomSelectInstance.setValue(father.id);
- } else {
- relSelect.value = father.id;
- }
- relTypeSelect.value = '1'; // 父子
- // Trigger change event if needed by other logic (not needed here yet)
- }
- } else if (person.matches.spouse && person.matches.spouse.length > 0) {
- const spouse = person.matches.spouse[0];
- const relSelect = form.querySelector('[name="related_mid"]');
- const relTypeSelect = form.querySelector('[name="relation_type"]');
-
- if (relSelect && relTypeSelect) {
- if (typeof tomSelectInstance !== 'undefined' && tomSelectInstance) {
- tomSelectInstance.setValue(spouse.id);
- } else {
- relSelect.value = spouse.id;
- }
- relTypeSelect.value = '10'; // 夫妻
- }
- }
- }
-
- // --- Show Details Panel ---
- const detailContainer = document.getElementById('aiCurrentDetail');
- const detailContent = document.getElementById('aiDetailContent');
-
- let html = '<ul class="list-unstyled mb-0 font-monospace" style="font-size: 0.85rem;">';
-
- const getLabel = (k) => fieldMapping[k] || (k === 'children' ? '子女' : k);
- // 遍历属性显示
- for (const key in person) {
- // 隐藏内部属性
- if (key.startsWith('_')) continue;
-
- let val = person[key];
- const label = getLabel(key);
- // 特殊处理 children
- if (key === 'children') {
- if (Array.isArray(val) && val.length > 0) {
- let childrenHtml = '<div class="d-flex flex-wrap gap-1 mt-1">';
- val.forEach(child => {
- // 使用 child._originalIndex 进行跳转填充
- 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>`;
- });
- childrenHtml += '</div>';
- html += `<li class="mb-1"><span class="text-info opacity-75">${label}:</span> ${childrenHtml}</li>`;
- }
- continue;
- }
-
- // 默认显示
- if (!val || val === '') val = '-';
- html += `<li class="mb-1"><span class="text-info opacity-75">${label}:</span> <span class="text-white ms-1">${val}</span></li>`;
- }
- html += '</ul>';
-
- detailContent.innerHTML = html;
- detailContainer.style.display = 'block';
-
- // Visual feedback
- const btn = document.getElementById(`btn-fill-${index}`);
- if(btn) {
- const originalHtml = btn.innerHTML;
- btn.innerHTML = '<i class="bi bi-check"></i> 已填';
- btn.classList.remove('btn-outline-info');
- btn.classList.add('btn-info', 'text-white');
- setTimeout(() => {
- btn.innerHTML = originalHtml;
- btn.classList.add('btn-outline-info');
- btn.classList.remove('btn-info', 'text-white');
- }, 1000);
- }
- }
- // --- Pre-fill Logic from Backend (Async AI Result) ---
- const prefilledContent = {{ prefilled_content | tojson | safe if prefilled_content else 'null' }};
- const sourceOssUrl = "{{ source_oss_url if source_oss_url else '' }}";
- const sourceRecordId = "{{ source_record_id if source_record_id else '' }}";
- if (prefilledContent && sourceOssUrl) {
- // We have prefilled content from DB, simulate "Recognize Image" success
- document.addEventListener('DOMContentLoaded', async () => {
- // Wait a bit for UI to settle
- setTimeout(async () => {
- // Find image index
- const imgIndex = images.findIndex(img => img.url === sourceOssUrl);
- if (imgIndex !== -1) {
- currentIndex = imgIndex;
- updateDisplay();
- }
-
- // Parse and display results
- try {
- let data = prefilledContent;
- if (typeof data === 'string') {
- try {
- data = JSON.parse(data);
- } catch(e) {
- console.error("Prefilled content parse error", e);
- return;
- }
- }
-
- if (!Array.isArray(data)) data = [data];
-
- await processAiData(data);
-
- // Open the log panel to show results
- const aiPanel = document.getElementById('aiLogPanel');
- if (aiPanel) aiPanel.style.display = 'block';
-
- const status = document.getElementById('reasoningStatus');
- if(status) {
- status.textContent = '已加载历史解析';
- status.className = 'badge bg-success ms-2';
- }
-
- const logContent = document.getElementById('aiLogContent');
- if(logContent) logContent.textContent = "已加载历史 AI 解析记录。";
-
- } catch (e) {
- console.error("Error processing prefilled content", e);
- }
- }, 500);
- });
- } else {
- // No prefilled content, initialize display for the first image
- document.addEventListener('DOMContentLoaded', () => {
- updateDisplay();
- });
- }
-
- // --- Name Cleaning Logic (Matching Backend) ---
- // 仅做繁 -> 简转换,不动姓氏/“公”处理,用于配偶等非留氏族人
- function manualSimplify(text) {
- if (!text) return text;
- text = text.trim();
- const mapping = {
- '學': '学', '國': '国', '萬': '万', '寶': '宝', '興': '兴',
- '華': '华', '會': '会', '葉': '叶', '藝': '艺', '號': '号',
- '處': '处', '見': '见', '視': '视', '言': '言', '語': '语',
- '貝': '贝', '車': '车', '長': '长', '門': '门', '韋': '韦',
- '頁': '页', '風': '风', '飛': '飞', '食': '食', '馬': '马',
- '魚': '鱼', '鳥': '鸟', '麥': '麦', '黃': '黄', '齊': '齐',
- '齒': '齿', '龍': '龙', '龜': '龟', '壽': '寿', '榮': '荣',
- '愛': '爱', '慶': '庆', '衛': '卫', '賢': '贤', '義': '义',
- '禮': '礼', '樂': '乐', '靈': '灵', '滅': '灭', '氣': '气',
- '智': '智', '信': '信', '仁': '仁', '勇': '勇', '嚴': '严',
- '劉': '刘'
- };
- let result = '';
- for (const ch of text) {
- result += mapping[ch] || ch;
- }
- return result;
- }
- // 留氏本人姓名清洗:在 manualSimplify 基础上,处理“留”姓和“公”
- function cleanName(name) {
- if (!name) return name;
- name = manualSimplify(name);
- const exceptions = ['学公', '留学公'];
- if (exceptions.includes(name)) {
- if (!name.startsWith('留')) {
- name = '留' + name;
- }
- return name;
- }
-
- // Remove '公' suffix
- if (name.endsWith('公')) {
- name = name.slice(0, -1);
- }
-
- // Ensure '留' prefix
- if (!name.startsWith('留')) {
- name = '留' + name;
- }
-
- return name;
- }
- function isFemaleSex(sexValue) {
- if (sexValue === null || sexValue === undefined) return false;
- const s = String(sexValue).trim().toLowerCase();
- return s === '女' || s === '2' || s === 'female' || s === 'f';
- }
- function normalizeLookupName(name) {
- if (!name) return '';
- return manualSimplify(String(name)).trim();
- }
- // Extracted function to process AI data and render tree
- async function processAiData(data) {
- const spouseNameSet = new Set();
- data.forEach(p => {
- const n = normalizeLookupName(p.spouse_name);
- if (n) spouseNameSet.add(n);
- });
- // Clean Names First
- data.forEach(p => {
- // Determine "Original" (Raw) and "Simplified" (Cleaned)
- let rawName = p.original_name || p.name;
- let simName = p.name || p.original_name; // Prefer AI simplified name; fallback to raw
- const ownName1 = normalizeLookupName(p.name);
- const ownName2 = normalizeLookupName(p.original_name);
- const isFemaleSpouse = isFemaleSex(p.sex) && (
- !!normalizeLookupName(p.spouse_name) ||
- (ownName1 && spouseNameSet.has(ownName1)) ||
- (ownName2 && spouseNameSet.has(ownName2))
- );
-
- // 女性配偶:只繁转简,不拼接“留”;其他人维持原规则
- p.simplified_name = isFemaleSpouse ? manualSimplify(simName) : cleanName(simName);
-
- // Set the name to be the Raw Name for storage in 'name' column
- p.name = rawName;
-
- // 父亲:同族,用 cleanName(加“留”、去“公”)
- if (p.father_name) p.father_name = cleanName(p.father_name);
- // 配偶:只做繁体 -> 简体,不拼接“留”姓
- if (p.spouse_name) p.spouse_name = manualSimplify(p.spouse_name);
- });
- // Call Relation Check API
- try {
- // Send simplified_name for checking relations if available, or name?
- // The API checks against DB 'name' column.
- // Wait, DB 'name' column is now Traditional Raw.
- // But existing data in DB is Simplified Cleaned.
- // New data will be Traditional Raw in 'name', Simplified Cleaned in 'simplified_name'.
- // The check_relations API uses `WHERE name IN (...)`.
- // The AI returns `father_name` as Simplified (usually).
- // So we are checking Simplified Father Name against...
- // If DB 'name' is mixed (Old Simplified, New Traditional), this is messy.
- // But `check_relations` logic:
- // `names_to_check.add(p['father_name'])` -> Simplified.
- // `SELECT ... WHERE name IN ...`
- // If DB 'name' contains Traditional, we won't find match if we search Simplified.
- // Unless we search `simplified_name` column too?
- // I should update `check_relations` in app.py to search both `name` and `simplified_name`.
-
- const checkRes = await fetch('/manager/api/check_relations', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ people: data })
- });
- const checkResult = await checkRes.json();
- if (checkResult.success && checkResult.matches) {
- // Merge matches into data
- for (const idx in checkResult.matches) {
- const match = checkResult.matches[idx];
- if (data[idx]) {
- data[idx].matches = match;
- }
- }
- }
- } catch (e) {
- console.warn("Auto-linking failed:", e);
- }
- currentParsedPeople = data;
- document.getElementById('resultCount').innerText = data.length;
-
- // Update Button State to "View Results"
- updateAiButtonState(true);
-
- // Build Relationship Tree
- const personMap = {};
- const roots = [];
-
- // 1. Initialize map
- data.forEach((p, index) => {
- p._originalIndex = index; // Store original index for fillForm
- p.children = [];
- // Use simplified_name as key if available, otherwise name (for consistent lookup)
- const lookupKey = p.simplified_name || p.name;
- personMap[lookupKey] = p;
- });
-
- // 2. Build Hierarchy
- data.forEach(p => {
- let parentFound = false;
- if (p.father_name) {
- // Try exact match using simplified name (since father_name is usually simplified)
- let father = personMap[p.father_name];
-
- // Try loose match
- if (!father) {
- for (const name in personMap) {
- if (name.includes(p.father_name) || p.father_name.includes(name)) {
- father = personMap[name];
- break;
- }
- }
- }
-
- if (father && father !== p) {
- father.children.push(p);
- parentFound = true;
- }
- }
-
- if (!parentFound) {
- roots.push(p);
- }
- });
-
- // 3. Recursive Render Function
- function renderNode(p, level = 0) {
- const indent = level * 20;
- let html = `
- <div class="card bg-dark border-secondary mb-1" style="margin-left: ${indent}px; background-color: #2c3034;">
- <div class="card-body p-2 d-flex justify-content-between align-items-center">
- <div class="text-white">
- <div class="fw-bold">
- ${level > 0 ? '<i class="bi bi-arrow-return-right text-secondary me-1"></i>' : ''}
- ${p.name || '未知姓名'}
- <span class="badge bg-secondary text-light ms-1" style="font-size: 0.7rem">${p.sex || '-'}</span>
- </div>
- <div class="small text-white-50" style="font-size: 0.75rem; padding-left: ${level > 0 ? 18 : 0}px;">
- ${p.generation ? '第'+p.generation+'世 ' : ''}
- ${p.father_name ? '父:'+p.father_name : ''}
- </div>
- </div>
- <button id="btn-fill-${p._originalIndex}"
- class="btn btn-sm ${p.is_imported ? 'btn-success disabled' : 'btn-outline-info'} text-nowrap ms-2"
- onclick="${p.is_imported ? '' : `fillForm(${p._originalIndex})`}">
- ${p.is_imported ? '<i class="bi bi-check-lg"></i> 已录入' : '<i class="bi bi-pencil-square"></i> 填充'}
- </button>
- </div>
- </div>
- `;
-
- if (p.children && p.children.length > 0) {
- p.children.forEach(child => {
- html += renderNode(child, level + 1);
- });
- }
-
- return html;
- }
-
- // Render List
- const resultList = document.getElementById('aiResultList');
- const resultSection = document.getElementById('aiResultSection');
-
- resultList.innerHTML = '';
-
- // Fix: Use data directly if root finding logic fails or returns empty but data exists
- if (roots.length === 0 && data.length > 0) {
- // Just dump everything flat if tree building fails
- data.forEach(p => resultList.innerHTML += renderNode(p, 0));
- } else {
- roots.forEach(p => {
- resultList.innerHTML += renderNode(p, 0);
- });
- }
-
- resultSection.style.display = 'block';
- }
- async function recognizeImage() {
- if (images.length === 0) {
- alert('没有可用的图片');
- return;
- }
- const currentImg = images[currentIndex];
- const btn = document.getElementById('aiBtn');
- const originalContent = btn.innerHTML;
- const logPanel = document.getElementById('aiLogPanel');
- const logContent = document.getElementById('aiLogContent');
- const resultSection = document.getElementById('aiResultSection');
- const resultList = document.getElementById('aiResultList');
- const reasoningStatus = document.getElementById('reasoningStatus');
- btn.disabled = true;
- btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 识别中...';
-
- // Reset UI
- logContent.textContent = '';
- resultList.innerHTML = '';
- resultSection.style.display = 'none';
- logPanel.style.display = 'block';
- reasoningStatus.textContent = '连接中...';
- reasoningStatus.className = 'badge bg-secondary ms-2';
-
- // Ensure reasoning panel is open
- const collapseReasoning = document.getElementById('collapseReasoning');
- if (collapseReasoning && !collapseReasoning.classList.contains('show')) {
- new bootstrap.Collapse(collapseReasoning, { show: true });
- }
- // Retry logic function
- async function fetchAndParse(url, retryCount = 0) {
- const MAX_RETRIES = 2;
- let fullText = '';
- let jsonPart = '';
- let hasJsonStarted = false;
- try {
- if (retryCount > 0) {
- logContent.textContent = `\n[System] 解析失败,正在进行第 ${retryCount} 次重试...\n` + logContent.textContent;
- reasoningStatus.textContent = `重试 ${retryCount}...`;
- }
- const response = await fetch('/manager/api/recognize_image', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ image_url: url })
- });
- if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- const separator = "|||JSON_START|||";
- while (true) {
- const { value, done } = await reader.read();
- if (done) break;
-
- const chunk = decoder.decode(value, { stream: true });
- fullText += chunk;
-
- // Only update display if not parsing JSON part yet or just started
- if (!hasJsonStarted) {
- const sepIndex = fullText.indexOf(separator);
- if (sepIndex !== -1) {
- hasJsonStarted = true;
- reasoningStatus.textContent = '解析中...';
- reasoningStatus.className = 'badge bg-info ms-2';
-
- // Split content for display - only once
- const reasoningPart = fullText.substring(0, sepIndex);
- logContent.textContent = reasoningPart;
-
- if (collapseReasoning) {
- new bootstrap.Collapse(collapseReasoning, { hide: true });
- }
- } else {
- // Update reasoning text
- logContent.textContent = fullText;
- logContent.scrollTop = logContent.scrollHeight;
- }
- }
- }
- // Parsing Logic
- if (hasJsonStarted) {
- const sepIndex = fullText.indexOf(separator);
- jsonPart = fullText.substring(sepIndex + separator.length);
- reasoningStatus.textContent = '完成';
- reasoningStatus.className = 'badge bg-success ms-2';
- } else {
- // Fallback
- jsonPart = fullText;
- }
-
- // Clean JSON
- // 1. Try finding [...] array
- let start = jsonPart.indexOf('[');
- let end = jsonPart.lastIndexOf(']');
-
- // 2. If not found, try finding {...} object and wrap it
- let isSingleObject = false;
- if (start === -1 || end === -1 || end <= start) {
- start = jsonPart.indexOf('{');
- end = jsonPart.lastIndexOf('}');
- isSingleObject = true;
- }
- if (start !== -1 && end !== -1 && end > start) {
- jsonPart = jsonPart.substring(start, end + 1);
- } else {
- // Try to extract any JSON-like array/object structure using regex as fallback
- const jsonMatch = jsonPart.match(/(\[.*\]|\{.*\})/s);
- if (jsonMatch) {
- jsonPart = jsonMatch[0];
- if (jsonPart.trim().startsWith('{')) isSingleObject = true;
- } else {
- // No valid JSON structure found
- console.warn("No JSON brackets found in:", jsonPart);
- throw new Error("未找到有效的 JSON 数据结构");
- }
- }
-
- let data;
- try {
- // Pre-clean: Remove common markdown code block markers if stuck inside
- jsonPart = jsonPart.replace(/^```json\s*/, '').replace(/```$/, '');
-
- data = JSON.parse(jsonPart);
- } catch (e) {
- // Attempt to fix common JSON errors (e.g. trailing commas, unclosed strings) - simplified
- console.error("JSON parse error. Content:", jsonPart);
- // Force retry on parse error
- throw new Error("JSON 格式解析错误");
- }
- if (isSingleObject && !Array.isArray(data)) {
- data = [data]; // Normalize to array
- } else if (!Array.isArray(data)) {
- data = [data];
- }
-
- return data;
- } catch (error) {
- if (retryCount < MAX_RETRIES) {
- // Wait 1s and retry
- await new Promise(r => setTimeout(r, 1000));
- return fetchAndParse(url, retryCount + 1);
- }
- throw error;
- }
- }
- try {
- const data = await fetchAndParse(currentImg.url);
-
- // Use shared processing function
- await processAiData(data);
- // Update local state for persistence during session
- if (images[currentIndex]) {
- images[currentIndex].ai_status = 2;
- images[currentIndex].ai_content = data;
- }
- } catch (error) {
- console.error(error);
- // Append error to log instead of overwriting valid reasoning
- logContent.textContent += `\n\n[Error] ${error.message}`;
- alert('AI 识别过程失败,请重试。\n错误详情: ' + error.message);
- } finally {
- btn.innerHTML = originalContent;
- btn.disabled = false;
- }
- }
- // Check for duplicate name
- let nameCheckTimeout = null;
- const nameInput = document.getElementById('nameInput');
- const nameCheckResult = document.getElementById('nameCheckResult');
-
- if (nameInput) {
- nameInput.addEventListener('input', function() {
- clearTimeout(nameCheckTimeout);
- const nameVal = this.value.trim();
- if (!nameVal) {
- nameCheckResult.innerHTML = '';
- return;
- }
-
- nameCheckTimeout = setTimeout(() => {
- fetch(`/manager/api/check_name?name=${encodeURIComponent(nameVal)}`)
- .then(r => r.json())
- .then(data => {
- if (data.success && data.exists) {
- let html = `<div class="alert alert-warning py-2 mb-0 mt-2 small">
- <i class="bi bi-exclamation-triangle-fill"></i> 发现 <strong>${data.matches.length}</strong> 个同名记录,请确认是否为同一人:
- <ul class="mb-0 mt-1 ps-3">`;
- data.matches.forEach(m => {
- let sex = m.sex === 1 ? '男' : (m.sex === 2 ? '女' : '未知');
- let deadStr = m.is_pass_away == 1 ? ' (已故)' : (m.is_pass_away == 2 ? ' (未知)' : '');
- html += `<li><a href="/manager/member_detail/${m.id}" target="_blank" class="alert-link">${m.name}</a> - ${sex} - 出生: ${m.birthday_str}${deadStr}</li>`;
- });
- html += `</ul></div>`;
- nameCheckResult.innerHTML = html;
- } else {
- nameCheckResult.innerHTML = '';
- }
- })
- .catch(err => console.error('Error checking name:', err));
- }, 600);
- });
- }
- // Auto-link spouse from notes if female
- const notesInput = document.querySelector('textarea[name="notes"]');
- const sexSelect = document.querySelector('select[name="sex"]');
-
- // Attach to window so fillForm can explicitly call it
- window.checkSpouseInNotes = function() {
- if (!notesInput || !sexSelect) return;
-
- // Only trigger if female
- if (sexSelect.value === '2') {
- const val = notesInput.value;
- // Match cases like "配偶:张三", "配偶:张三", "配偶 张三", "配偶张三"
- // We use a robust regex to get the word after 配偶
- const match = val.match(/配偶[::\s]*([^\s;;,,。]+)/);
- if (match && match[1]) {
- const spouseName = match[1].trim();
- const normalizedSpouse = spouseName.replace(/公$/, '').replace(/^留/, '');
- const relatedSelect = document.querySelector('select[name="related_mid"]');
- const relationTypeSelect = document.querySelector('select[name="relation_type"]');
-
- if (relatedSelect && relationTypeSelect) {
- for (let i = 0; i < relatedSelect.options.length; i++) {
- const opt = relatedSelect.options[i];
- if (!opt.value) continue;
-
- const optText = opt.text.trim();
- // Extract name before " (ID:" robustly
- const optName = optText.replace(/\s*\(ID:.*$/, '').trim();
- const normalizedOpt = optName.replace(/公$/, '').replace(/^留/, '');
-
- // Match exact or without '公' suffix and without '留' prefix
- if (optName === spouseName || normalizedOpt === normalizedSpouse ||
- (normalizedOpt && normalizedSpouse && (normalizedOpt.includes(normalizedSpouse) || normalizedSpouse.includes(normalizedOpt)))) {
- // If not already selected, select it and set relation to Spouse
- if (relatedSelect.value !== opt.value) {
- if (typeof tomSelectInstance !== 'undefined' && tomSelectInstance) {
- tomSelectInstance.setValue(opt.value);
- } else {
- relatedSelect.value = opt.value;
- }
- relationTypeSelect.value = '10'; // 10 is Spouse
-
- // Optional visual feedback to user
- notesInput.style.transition = "background-color 0.3s";
- notesInput.style.backgroundColor = "#e8f5e9";
- setTimeout(() => notesInput.style.backgroundColor = "", 1000);
-
- console.log("Auto-linked spouse: ", optName);
- }
- break;
- }
- }
- }
- }
- }
- };
-
- if (notesInput && sexSelect) {
- notesInput.addEventListener('input', window.checkSpouseInNotes);
- sexSelect.addEventListener('change', window.checkSpouseInNotes);
- }
- </script>
- <script>
- document.addEventListener('DOMContentLoaded', function() {
- const container = document.getElementById('lineage-generations-container');
- const addButton = document.getElementById('add-lineage');
- const inputForm = document.getElementById('lineage-input-form');
- const lineageInput = document.getElementById('lineage-input');
- const confirmButton = document.getElementById('confirm-lineage');
- const cancelButton = document.getElementById('cancel-lineage');
- if (!container || !addButton || !inputForm || !lineageInput || !confirmButton || !cancelButton) return;
- function addLineageTag(value) {
- const tag = document.createElement('span');
- tag.className = 'lineage-tag badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 px-3 py-2 rounded-pill d-inline-flex align-items-center';
- tag.style.fontSize = '0.85rem';
- tag.innerHTML = `${value}<button type="button" class="btn-close ms-2 remove-lineage" style="font-size:0.55rem;filter:none;opacity:0.6;" aria-label="删除"></button><input type="hidden" name="lineage_generations[]" value="${value}">`;
- container.insertBefore(tag, inputForm);
- }
- container.addEventListener('click', function(e) {
- const removeBtn = e.target.closest('.remove-lineage');
- if (removeBtn) {
- e.preventDefault();
- e.stopPropagation();
- const tag = removeBtn.closest('.lineage-tag');
- if (tag) tag.remove();
- }
- });
- addButton.addEventListener('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
- inputForm.classList.remove('d-none');
- addButton.classList.add('d-none');
- lineageInput.value = '';
- lineageInput.focus();
- });
- function confirmInput() {
- const value = lineageInput.value.trim();
- if (value) {
- addLineageTag(value);
- }
- lineageInput.value = '';
- inputForm.classList.add('d-none');
- addButton.classList.remove('d-none');
- }
- confirmButton.addEventListener('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
- confirmInput();
- });
- lineageInput.addEventListener('keydown', function(e) {
- if (e.key === 'Enter') {
- e.preventDefault();
- e.stopPropagation();
- confirmInput();
- }
- if (e.key === 'Escape') {
- e.preventDefault();
- lineageInput.value = '';
- inputForm.classList.add('d-none');
- addButton.classList.remove('d-none');
- }
- });
- cancelButton.addEventListener('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
- lineageInput.value = '';
- inputForm.classList.add('d-none');
- addButton.classList.remove('d-none');
- });
- });
- </script>
- {% endblock %}
|