settlements.html 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895
  1. {% extends 'layout.html' %}
  2. {% block content %}
  3. <style>
  4. /* 水滴标记 hover 放大,锚点在尖端底部 */
  5. .settlement-drop-marker:hover {
  6. transform: scale(1.25) !important;
  7. }
  8. </style>
  9. <script>
  10. window.isSuperAdmin = {{ session.get('is_super_admin') | tojson }};
  11. </script>
  12. <script type="text/javascript">
  13. window.AMapPromise = new Promise(function(resolve, reject) {
  14. if (typeof AMap !== 'undefined') {
  15. resolve(AMap);
  16. return;
  17. }
  18. const script = document.createElement('script');
  19. script.src = 'https://webapi.amap.com/maps?v=2.0&key=7bbc2afa6f49b58024780bf647c6f038';
  20. script.onload = function() { resolve(AMap); };
  21. script.onerror = function() { reject('高德地图加载失败'); };
  22. document.head.appendChild(script);
  23. });
  24. </script>
  25. <div class="container-fluid">
  26. <div class="row">
  27. <div class="col-md-12">
  28. <div class="d-flex justify-content-between align-items-center mb-4">
  29. <h2 class="page-header">聚落地图</h2>
  30. <div class="d-flex gap-2">
  31. <div class="btn-group" role="group">
  32. <button type="button" class="btn btn-primary" id="view-map-btn">地图查看</button>
  33. <button type="button" class="btn btn-outline-primary" id="view-list-btn">列表查看</button>
  34. </div>
  35. {% if session.get('is_super_admin') %}
  36. <button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addSettlementModal">
  37. <i class="bi bi-plus-circle"></i> 添加聚落
  38. </button>
  39. {% endif %}
  40. </div>
  41. </div>
  42. <div id="map-view" class="mb-4" style="display: block;">
  43. <div id="map-container" style="min-height: 500px; max-height: 80vh; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden;">
  44. <div class="map-loading" style="position: absolute; inset: 0; background: linear-gradient(135deg, #e0f2fe 0%, #f0f9ff 100%); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 100;">
  45. <div class="spinner-border text-primary" role="status">
  46. <span class="visually-hidden">加载中...</span>
  47. </div>
  48. <p class="mt-3 text-gray-500">地图加载中...</p>
  49. </div>
  50. </div>
  51. </div>
  52. <div id="list-view" class="mb-4" style="display: none;">
  53. <div class="card">
  54. <div class="card-body">
  55. <div class="table-responsive">
  56. <table class="table table-hover">
  57. <thead>
  58. <tr>
  59. <th>编号</th>
  60. <th>聚落名称</th>
  61. <th>所属区域</th>
  62. <th>人数</th>
  63. <th>代表人物</th>
  64. <th>姓氏类型</th>
  65. <th>创建时间</th>
  66. {% if session.get('is_super_admin') %}
  67. <th>操作</th>
  68. {% endif %}
  69. </tr>
  70. </thead>
  71. <tbody id="settlements-table-body">
  72. </tbody>
  73. </table>
  74. </div>
  75. </div>
  76. </div>
  77. </div>
  78. </div>
  79. </div>
  80. </div>
  81. <div class="modal fade" id="addSettlementModal" tabindex="-1" aria-labelledby="addSettlementModalLabel" aria-hidden="true">
  82. <div class="modal-dialog modal-lg">
  83. <div class="modal-content">
  84. <div class="modal-header">
  85. <h5 class="modal-title" id="addSettlementModalLabel">添加聚落</h5>
  86. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  87. </div>
  88. <div class="modal-body">
  89. <form id="settlement-form">
  90. <input type="hidden" id="settlement-id">
  91. <div class="row g-3">
  92. <div class="col-md-6">
  93. <label class="form-label">聚落名称 *</label>
  94. <input type="text" class="form-control" id="settlement-name" required>
  95. </div>
  96. <div class="col-md-6">
  97. <label class="form-label">所属区域 *</label>
  98. <div class="input-group">
  99. <input type="text" class="form-control" id="settlement-region" placeholder="搜索城市或区域名称" required>
  100. <button type="button" class="btn btn-outline-primary" id="search-region-btn">
  101. <i class="bi bi-search"></i>
  102. </button>
  103. </div>
  104. <div id="region-suggestions" class="mt-2" style="display: none;"></div>
  105. </div>
  106. <div class="col-md-6 d-none">
  107. <label class="form-label">纬度</label>
  108. <input type="number" class="form-control" id="settlement-latitude" step="0.0000001">
  109. </div>
  110. <div class="col-md-6 d-none">
  111. <label class="form-label">经度</label>
  112. <input type="number" class="form-control" id="settlement-longitude" step="0.0000001">
  113. </div>
  114. <div class="col-md-12">
  115. <div class="alert alert-info alert-dismissible fade show" id="location-info" style="display: none;">
  116. <i class="bi bi-info-circle me-2"></i>
  117. <span id="location-info-text"></span>
  118. <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
  119. </div>
  120. </div>
  121. <div class="col-md-6">
  122. <label class="form-label">聚落人数</label>
  123. <input type="number" class="form-control" id="settlement-population" min="0">
  124. </div>
  125. <div class="col-md-6">
  126. <label class="form-label">代表人物</label>
  127. <div class="input-group">
  128. <input type="text" id="settlement-representative-display" class="form-control" placeholder="点击选择代表人物" readonly>
  129. <input type="hidden" id="settlement-representative-id">
  130. <button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#memberSelectModal">
  131. <i class="bi bi-search"></i>
  132. </button>
  133. </div>
  134. </div>
  135. <div class="col-md-6">
  136. <label class="form-label">姓氏类型</label>
  137. <select class="form-select" id="settlement-surname-type">
  138. <option value="0">留姓</option>
  139. <option value="1">改姓</option>
  140. </select>
  141. </div>
  142. <div class="col-md-6" id="new-surname-container" style="display: none;">
  143. <label class="form-label">改后姓氏</label>
  144. <input type="text" class="form-control" id="settlement-new-surname" placeholder="输入改后的姓氏">
  145. </div>
  146. <div class="col-md-12">
  147. <label class="form-label">备注说明</label>
  148. <textarea class="form-control" id="settlement-description" rows="3"></textarea>
  149. </div>
  150. {% if session.get('is_super_admin') %}
  151. <div class="col-md-12">
  152. <label class="form-label">热心宗亲</label>
  153. <input type="text" class="form-control" id="settlement-enthusiastic-members" placeholder="多人请用逗号分隔,如:张三,李四,王五">
  154. <div class="form-text">仅超级管理员可见,录入后在地图hover和详情中显示</div>
  155. </div>
  156. {% endif %}
  157. </div>
  158. </form>
  159. </div>
  160. <div class="modal-footer">
  161. <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
  162. <button type="button" class="btn btn-primary" id="save-settlement-btn">保存</button>
  163. </div>
  164. </div>
  165. </div>
  166. </div>
  167. <div class="modal fade" id="memberSelectModal" tabindex="-1" aria-labelledby="memberSelectModalLabel" aria-hidden="true">
  168. <div class="modal-dialog modal-lg">
  169. <div class="modal-content">
  170. <div class="modal-header">
  171. <h5 class="modal-title" id="memberSelectModalLabel">选择代表人物</h5>
  172. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  173. </div>
  174. <div class="modal-body">
  175. <div class="mb-3">
  176. <input type="text" id="member-search-input" class="form-control" placeholder="搜索成员名称...">
  177. </div>
  178. <div id="member-list-container" style="max-height: 400px; overflow-y: auto;">
  179. </div>
  180. </div>
  181. <div class="modal-footer">
  182. <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
  183. </div>
  184. </div>
  185. </div>
  186. </div>
  187. <div class="modal fade" id="settlementDetailModal" tabindex="-1" aria-labelledby="settlementDetailModalLabel" aria-hidden="true">
  188. <div class="modal-dialog">
  189. <div class="modal-content">
  190. <div class="modal-header">
  191. <h5 class="modal-title" id="settlementDetailModalLabel">聚落详情</h5>
  192. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  193. </div>
  194. <div class="modal-body" id="settlement-detail-content">
  195. </div>
  196. <div class="modal-footer">
  197. <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
  198. {% if session.get('is_super_admin') %}
  199. <button type="button" class="btn btn-primary" id="edit-settlement-btn">编辑</button>
  200. {% endif %}
  201. </div>
  202. </div>
  203. </div>
  204. </div>
  205. <script>
  206. let map = null;
  207. let markers = [];
  208. let settlementsData = [];
  209. function resizeMapContainer() {
  210. const mapContainer = document.getElementById('map-container');
  211. const headerHeight = document.querySelector('.page-header')?.offsetHeight || 80;
  212. const toolbarHeight = 60;
  213. const padding = 40;
  214. const windowHeight = window.innerHeight;
  215. const sidebarWidth = 250;
  216. const availableHeight = windowHeight - headerHeight - toolbarHeight - padding;
  217. if (window.innerWidth > 768) {
  218. mapContainer.style.height = Math.max(500, Math.min(availableHeight, windowHeight * 0.85)) + 'px';
  219. } else {
  220. mapContainer.style.height = Math.max(400, availableHeight * 0.8) + 'px';
  221. }
  222. if (window.map) {
  223. window.map.resize();
  224. }
  225. }
  226. document.addEventListener('DOMContentLoaded', function() {
  227. loadSettlements();
  228. initRegionSearch();
  229. resizeMapContainer();
  230. window.addEventListener('resize', resizeMapContainer);
  231. document.getElementById('addSettlementModal').addEventListener('show.bs.modal', function() {
  232. if (!window.isEditingSettlement) {
  233. resetSettlementForm();
  234. }
  235. window.isEditingSettlement = false;
  236. });
  237. document.getElementById('view-map-btn').addEventListener('click', function() {
  238. document.getElementById('map-view').style.display = 'block';
  239. document.getElementById('list-view').style.display = 'none';
  240. this.classList.add('btn-primary');
  241. this.classList.remove('btn-outline-primary');
  242. document.getElementById('view-list-btn').classList.add('btn-outline-primary');
  243. document.getElementById('view-list-btn').classList.remove('btn-primary');
  244. });
  245. document.getElementById('view-list-btn').addEventListener('click', function() {
  246. document.getElementById('map-view').style.display = 'none';
  247. document.getElementById('list-view').style.display = 'block';
  248. this.classList.add('btn-primary');
  249. this.classList.remove('btn-outline-primary');
  250. document.getElementById('view-map-btn').classList.add('btn-outline-primary');
  251. document.getElementById('view-map-btn').classList.remove('btn-primary');
  252. });
  253. document.getElementById('member-search-input').addEventListener('input', function() {
  254. searchMembers(this.value);
  255. });
  256. document.getElementById('save-settlement-btn').addEventListener('click', saveSettlement);
  257. document.getElementById('edit-settlement-btn').addEventListener('click', function() {
  258. const id = document.getElementById('settlement-id').value;
  259. if (id) {
  260. loadSettlementForEdit(id);
  261. }
  262. const modal = bootstrap.Modal.getInstance(document.getElementById('settlementDetailModal'));
  263. modal.hide();
  264. });
  265. document.getElementById('settlement-surname-type').addEventListener('change', function() {
  266. toggleNewSurnameField(this.value);
  267. });
  268. });
  269. function loadSettlements() {
  270. fetch('/manager/api/settlements', { credentials: 'include' })
  271. .then(response => response.json())
  272. .then(data => {
  273. if (data.success) {
  274. settlementsData = data.settlements;
  275. renderSettlementList(data.settlements);
  276. initMapWithData(data.settlements);
  277. }
  278. })
  279. .catch(error => {
  280. console.error('加载聚落失败:', error);
  281. });
  282. }
  283. function initMapWithData(settlements) {
  284. window.AMapPromise.then(function(AMap) {
  285. document.querySelector('.map-loading').style.display = 'none';
  286. if (!map) {
  287. let initialCenter = [116.397428, 39.90923];
  288. let initialZoom = 4;
  289. if (settlements.length > 0) {
  290. const firstLng = parseFloat(settlements[0].longitude) || 116.397428;
  291. const firstLat = parseFloat(settlements[0].latitude) || 39.90923;
  292. initialCenter = [firstLng, firstLat];
  293. initialZoom = settlements.length === 1 ? 10 : 6;
  294. }
  295. map = new AMap.Map('map-container', {
  296. center: initialCenter,
  297. zoom: initialZoom,
  298. resizeEnable: true,
  299. mapStyle: 'amap://styles/normal',
  300. features: ['bg', 'road', 'building', 'point', 'label'],
  301. viewMode: '2D'
  302. });
  303. AMap.plugin(['AMap.Scale', 'AMap.ToolBar', 'AMap.LabelsLayer'], function() {
  304. map.addControl(new AMap.Scale({ position: 'LB' }));
  305. map.addControl(new AMap.ToolBar({ position: 'RB' }));
  306. });
  307. }
  308. clearMarkers();
  309. if (settlements.length > 0) {
  310. const bounds = new AMap.Bounds();
  311. settlements.forEach(settlement => {
  312. const lng = parseFloat(settlement.longitude) || 116.397428;
  313. const lat = parseFloat(settlement.latitude) || 39.90923;
  314. // 水滴型标记(SVG:圆头朝上,尖端朝下)
  315. // hover 放大效果用 CSS :hover 实现,不依赖 getContent() DOM 操作
  316. const markerContent = `
  317. <div class="settlement-drop-marker" title="${settlement.name}" style="
  318. position: relative; width: 32px; height: 42px; cursor: pointer;
  319. filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35));
  320. transition: transform 0.15s ease; transform-origin: bottom center;
  321. ">
  322. <svg viewBox="0 0 32 42" xmlns="http://www.w3.org/2000/svg" width="32" height="42">
  323. <path d="M16 0 C7.163 0 0 7.163 0 16 C0 26 16 42 16 42 C16 42 32 26 32 16 C32 7.163 24.837 0 16 0 Z"
  324. fill="#3B82F6" stroke="#1D4ED8" stroke-width="1.5"/>
  325. <circle cx="16" cy="15" r="7" fill="white" opacity="0.9"/>
  326. </svg>
  327. </div>`;
  328. const marker = new AMap.Marker({
  329. position: [lng, lat],
  330. content: markerContent,
  331. offset: new AMap.Pixel(-16, -42),
  332. cursor: 'pointer'
  333. });
  334. marker.settlementData = settlement;
  335. marker.on('click', function() { showSettlementDetail(settlement); });
  336. marker.on('mouseover', function(e) { showMarkerTooltip(e, settlement); });
  337. marker.on('mousemove', function(e) { showMarkerTooltip(e, settlement); });
  338. marker.on('mouseout', function() { hideMarkerTooltip(); });
  339. map.add(marker);
  340. markers.push(marker);
  341. bounds.extend([lng, lat]);
  342. });
  343. if (settlements.length === 1) {
  344. const lng = parseFloat(settlements[0].longitude) || 116.397428;
  345. const lat = parseFloat(settlements[0].latitude) || 39.90923;
  346. map.setCenter([lng, lat]);
  347. map.setZoom(10);
  348. } else {
  349. setTimeout(function() {
  350. map.setFitView(bounds, false, [50, 50, 50, 50]);
  351. }, 100);
  352. }
  353. }
  354. }).catch(function(err) {
  355. console.error('高德地图加载失败:', err);
  356. document.querySelector('.map-loading p').textContent = '地图加载失败';
  357. });
  358. }
  359. function clearMarkers() {
  360. markers.forEach(marker => { map.remove(marker); });
  361. markers = [];
  362. }
  363. function renderSettlementList(settlements) {
  364. const tbody = document.getElementById('settlements-table-body');
  365. tbody.innerHTML = '';
  366. if (settlements.length === 0) {
  367. tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-500">暂无聚落数据</td></tr>';
  368. return;
  369. }
  370. settlements.forEach((settlement, index) => {
  371. const surnameType = getSurnameTypeText(settlement.surname_type);
  372. const newSurname = settlement.new_surname ? '(' + settlement.new_surname + ')' : '';
  373. const row = document.createElement('tr');
  374. row.innerHTML = `
  375. <td>${index + 1}</td>
  376. <td>${settlement.name}</td>
  377. <td>${settlement.region || '-'}</td>
  378. <td>${settlement.population || 0}</td>
  379. <td>${settlement.representative_name || '-'}</td>
  380. <td>${surnameType}${newSurname}</td>
  381. <td>${formatDateTime(settlement.created_at)}</td>
  382. ${window.isSuperAdmin ? `
  383. <td>
  384. <button class="btn btn-sm btn-primary edit-settlement-btn" data-id="${settlement.id}">编辑</button>
  385. <button class="btn btn-sm btn-danger delete-settlement-btn" data-id="${settlement.id}">删除</button>
  386. </td>
  387. ` : ''}
  388. `;
  389. row.addEventListener('click', function() { showSettlementDetail(settlement); });
  390. tbody.appendChild(row);
  391. });
  392. document.querySelectorAll('.edit-settlement-btn').forEach(btn => {
  393. btn.addEventListener('click', function(e) {
  394. e.stopPropagation();
  395. loadSettlementForEdit(this.dataset.id);
  396. });
  397. });
  398. document.querySelectorAll('.delete-settlement-btn').forEach(btn => {
  399. btn.addEventListener('click', function(e) {
  400. e.stopPropagation();
  401. if (confirm('确定要删除这个聚落吗?')) {
  402. deleteSettlement(this.dataset.id);
  403. }
  404. });
  405. });
  406. }
  407. function showSettlementDetail(settlement) {
  408. document.getElementById('settlement-id').value = settlement.id;
  409. const surnameType = getSurnameTypeText(settlement.surname_type);
  410. const content = document.getElementById('settlement-detail-content');
  411. content.innerHTML = `
  412. <div class="mb-4">
  413. <h4>${settlement.name}</h4>
  414. ${settlement.region ? '<p class="text-muted">区域:' + settlement.region + '</p>' : ''}
  415. </div>
  416. <div class="row">
  417. <div class="col-md-6">
  418. <div class="card">
  419. <div class="card-body">
  420. <h6 class="card-title">基本信息</h6>
  421. <p class="card-text"><strong>人数:</strong>${settlement.population || 0} 人</p>
  422. ${settlement.latitude ? '<p class="card-text"><strong>纬度:</strong>' + settlement.latitude + '</p>' : ''}
  423. ${settlement.longitude ? '<p class="card-text"><strong>经度:</strong>' + settlement.longitude + '</p>' : ''}
  424. <p class="card-text"><strong>姓氏类型:</strong>${surnameType}${settlement.new_surname ? '(改后:' + settlement.new_surname + ')' : ''}</p>
  425. </div>
  426. </div>
  427. </div>
  428. <div class="col-md-6">
  429. <div class="card">
  430. <div class="card-body">
  431. <h6 class="card-title">代表人物</h6>
  432. <p class="card-text">${settlement.representative_name || '未设置'}</p>
  433. </div>
  434. </div>
  435. </div>
  436. </div>
  437. ${settlement.description ? '<div class="mt-4"><h6>备注说明</h6><p>' + settlement.description + '</p></div>' : ''}
  438. ${window.isSuperAdmin && settlement.enthusiastic_members ? '<div class="mt-4"><h6 style="color:#d97706;">热心宗亲</h6><p>' + settlement.enthusiastic_members + '</p></div>' : ''}
  439. <div class="mt-4 text-muted text-sm">
  440. 创建时间:${formatDateTime(settlement.created_at)}
  441. ${settlement.updated_at !== settlement.created_at ? '<br>更新时间:' + formatDateTime(settlement.updated_at) : ''}
  442. </div>
  443. `;
  444. const modal = new bootstrap.Modal(document.getElementById('settlementDetailModal'));
  445. modal.show();
  446. }
  447. function showSettlementDetailById(id) {
  448. fetch('/manager/api/settlements/' + id)
  449. .then(response => response.json())
  450. .then(data => {
  451. if (data.success) {
  452. showSettlementDetail(data.settlement);
  453. }
  454. });
  455. }
  456. function loadSettlementForEdit(id) {
  457. fetch('/manager/api/settlements/' + id)
  458. .then(response => response.json())
  459. .then(data => {
  460. if (data.success) {
  461. const s = data.settlement;
  462. document.getElementById('settlement-id').value = s.id;
  463. document.getElementById('settlement-name').value = s.name;
  464. document.getElementById('settlement-region').value = s.region || '';
  465. document.getElementById('settlement-latitude').value = s.latitude || '';
  466. document.getElementById('settlement-longitude').value = s.longitude || '';
  467. document.getElementById('settlement-population').value = s.population || '';
  468. document.getElementById('settlement-representative-id').value = s.representative_id || '';
  469. document.getElementById('settlement-representative-display').value = s.representative_name || '';
  470. document.getElementById('settlement-description').value = s.description || '';
  471. document.getElementById('settlement-surname-type').value = s.surname_type || 0;
  472. document.getElementById('settlement-new-surname').value = s.new_surname || '';
  473. const emEl2 = document.getElementById('settlement-enthusiastic-members');
  474. if (emEl2) emEl2.value = s.enthusiastic_members || '';
  475. toggleNewSurnameField(s.surname_type);
  476. document.getElementById('addSettlementModalLabel').textContent = '编辑聚落';
  477. if (s.region && s.latitude) {
  478. document.getElementById('location-info-text').innerHTML = '已选择区域:' + s.region + ',坐标:' + s.latitude + ', ' + s.longitude;
  479. document.getElementById('location-info').style.display = 'block';
  480. }
  481. window.isEditingSettlement = true;
  482. const modal = new bootstrap.Modal(document.getElementById('addSettlementModal'));
  483. modal.show();
  484. }
  485. });
  486. }
  487. function resetSettlementForm() {
  488. document.getElementById('settlement-id').value = '';
  489. document.getElementById('settlement-name').value = '';
  490. document.getElementById('settlement-region').value = '';
  491. document.getElementById('settlement-latitude').value = '';
  492. document.getElementById('settlement-longitude').value = '';
  493. document.getElementById('settlement-population').value = '';
  494. document.getElementById('settlement-representative-id').value = '';
  495. document.getElementById('settlement-representative-display').value = '';
  496. document.getElementById('settlement-description').value = '';
  497. document.getElementById('settlement-surname-type').value = '0';
  498. document.getElementById('settlement-new-surname').value = '';
  499. const emEl = document.getElementById('settlement-enthusiastic-members');
  500. if (emEl) emEl.value = '';
  501. document.getElementById('location-info').style.display = 'none';
  502. document.getElementById('region-suggestions').style.display = 'none';
  503. document.getElementById('new-surname-container').style.display = 'none';
  504. document.getElementById('addSettlementModalLabel').textContent = '添加聚落';
  505. }
  506. function saveSettlement() {
  507. const emSaveEl = document.getElementById('settlement-enthusiastic-members');
  508. const data = {
  509. id: document.getElementById('settlement-id').value,
  510. name: document.getElementById('settlement-name').value,
  511. region: document.getElementById('settlement-region').value,
  512. latitude: document.getElementById('settlement-latitude').value,
  513. longitude: document.getElementById('settlement-longitude').value,
  514. population: document.getElementById('settlement-population').value,
  515. representative_id: document.getElementById('settlement-representative-id').value,
  516. description: document.getElementById('settlement-description').value,
  517. surname_type: document.getElementById('settlement-surname-type').value,
  518. new_surname: document.getElementById('settlement-new-surname').value,
  519. enthusiastic_members: emSaveEl ? emSaveEl.value : ''
  520. };
  521. const url = data.id ? '/manager/api/settlements/' + data.id : '/manager/api/settlements';
  522. const method = data.id ? 'PUT' : 'POST';
  523. fetch(url, {
  524. method: method,
  525. headers: { 'Content-Type': 'application/json' },
  526. body: JSON.stringify(data),
  527. credentials: 'include'
  528. })
  529. .then(response => response.json())
  530. .then(data => {
  531. if (data.success) {
  532. alert('保存成功!');
  533. const modal = bootstrap.Modal.getInstance(document.getElementById('addSettlementModal'));
  534. modal.hide();
  535. loadSettlements();
  536. } else {
  537. alert(data.message || '保存失败');
  538. }
  539. })
  540. .catch(error => {
  541. console.error('保存失败:', error);
  542. alert('保存失败,请检查网络连接');
  543. });
  544. }
  545. function deleteSettlement(id) {
  546. fetch('/manager/api/settlements/' + id, {
  547. method: 'DELETE',
  548. credentials: 'include'
  549. })
  550. .then(response => response.json())
  551. .then(data => {
  552. if (data.success) {
  553. loadSettlements();
  554. } else {
  555. alert(data.message || '删除失败');
  556. }
  557. });
  558. }
  559. function searchMembers(keyword) {
  560. fetch('/manager/api/search_member', {
  561. method: 'POST',
  562. headers: { 'Content-Type': 'application/json' },
  563. body: JSON.stringify({ keyword: keyword })
  564. })
  565. .then(response => response.json())
  566. .then(data => {
  567. const container = document.getElementById('member-list-container');
  568. if (data.success && data.members.length > 0) {
  569. container.innerHTML = data.members.map(member => {
  570. const simplified = member.simplified_name && member.simplified_name !== member.name ? '(' + member.simplified_name + ')' : '';
  571. return '<div class="member-item p-3 border-bottom cursor-pointer hover:bg-gray-50" onclick="selectRepresentative(' + member.id + ', \'' + member.name + '\', \'' + (member.simplified_name || '') + '\')">' +
  572. '<div style="font-weight: 500;">' + member.name + '</div>' +
  573. (member.simplified_name ? '<div style="font-size: 12px; color: #64748b;">' + simplified + '</div>' : '') +
  574. '</div>';
  575. }).join('');
  576. } else {
  577. container.innerHTML = '<div class="text-center py-4 text-gray-500">未找到匹配的成员</div>';
  578. }
  579. });
  580. }
  581. function selectRepresentative(id, name, simplifiedName) {
  582. document.getElementById('settlement-representative-id').value = id;
  583. let displayText = name;
  584. if (simplifiedName && simplifiedName !== name) {
  585. displayText += ' (' + simplifiedName + ')';
  586. }
  587. document.getElementById('settlement-representative-display').value = displayText;
  588. const modal = bootstrap.Modal.getInstance(document.getElementById('memberSelectModal'));
  589. modal.hide();
  590. }
  591. function formatDateTime(dateStr) {
  592. if (!dateStr) return '-';
  593. const date = new Date(dateStr);
  594. return date.toLocaleString('zh-CN');
  595. }
  596. function initRegionSearch() {
  597. const regionInput = document.getElementById('settlement-region');
  598. const suggestionsDiv = document.getElementById('region-suggestions');
  599. const searchBtn = document.getElementById('search-region-btn');
  600. searchBtn.addEventListener('click', function() {
  601. const keyword = regionInput.value.trim();
  602. if (keyword) {
  603. searchRegion(keyword);
  604. }
  605. });
  606. let searchTimeout = null;
  607. regionInput.addEventListener('input', function() {
  608. const keyword = this.value.trim();
  609. if (searchTimeout) clearTimeout(searchTimeout);
  610. if (keyword.length >= 2) {
  611. searchTimeout = setTimeout(() => { searchRegion(keyword); }, 300);
  612. } else {
  613. suggestionsDiv.style.display = 'none';
  614. }
  615. });
  616. regionInput.addEventListener('keypress', function(e) {
  617. if (e.key === 'Enter') {
  618. e.preventDefault();
  619. const keyword = this.value.trim();
  620. if (keyword) {
  621. searchRegion(keyword);
  622. }
  623. }
  624. });
  625. document.addEventListener('click', function(e) {
  626. if (!e.target.closest('#settlement-region') && !e.target.closest('#search-region-btn') && !e.target.closest('#region-suggestions')) {
  627. suggestionsDiv.style.display = 'none';
  628. }
  629. });
  630. }
  631. function searchRegion(keyword) {
  632. const suggestionsDiv = document.getElementById('region-suggestions');
  633. window.AMapPromise.then(function(AMap) {
  634. AMap.plugin(['AMap.PlaceSearch'], function() {
  635. const placeSearch = new AMap.PlaceSearch({
  636. city: '全国',
  637. type: 'district',
  638. pageSize: 10
  639. });
  640. placeSearch.search(keyword, function(status, result) {
  641. if (status === 'complete' && result && result.poiList && result.poiList.pois.length > 0) {
  642. const pois = result.poiList.pois;
  643. const formattedData = pois.map(poi => ({
  644. name: poi.name,
  645. adcode: poi.adcode,
  646. address: poi.address || poi.name,
  647. location: {
  648. lng: poi.location.lng || poi.location[0],
  649. lat: poi.location.lat || poi.location[1]
  650. }
  651. }));
  652. renderRegionSuggestions(formattedData);
  653. } else {
  654. searchRegionFallback(keyword);
  655. }
  656. });
  657. });
  658. }).catch(function() {
  659. searchRegionFallback(keyword);
  660. });
  661. }
  662. function searchRegionFallback(keyword) {
  663. const suggestionsDiv = document.getElementById('region-suggestions');
  664. const localData = [
  665. { name: '泉州市鲤城区', adcode: '350502', address: '福建省泉州市鲤城区', location: { lng: 118.58809, lat: 24.90757 } },
  666. { name: '泉州市丰泽区', adcode: '350503', address: '福建省泉州市丰泽区', location: { lng: 118.62062, lat: 24.88269 } },
  667. { name: '泉州市洛江区', adcode: '350504', address: '福建省泉州市洛江区', location: { lng: 118.74387, lat: 24.92516 } },
  668. { name: '泉州市泉港区', adcode: '350505', address: '福建省泉州市泉港区', location: { lng: 118.96936, lat: 25.13699 } },
  669. { name: '泉州市永春县', adcode: '350525', address: '福建省泉州市永春县', location: { lng: 118.38267, lat: 25.34437 } },
  670. { name: '泉州市安溪县', adcode: '350524', address: '福建省泉州市安溪县', location: { lng: 118.18598, lat: 25.06531 } },
  671. { name: '泉州市德化县', adcode: '350526', address: '福建省泉州市德化县', location: { lng: 118.15722, lat: 25.53543 } },
  672. { name: '泉州市金门县', adcode: '350527', address: '福建省泉州市金门县', location: { lng: 118.32556, lat: 24.43788 } },
  673. { name: '泉州市南安市', adcode: '350583', address: '福建省泉州市南安市', location: { lng: 118.32211, lat: 24.96694 } },
  674. { name: '泉州市晋江市', adcode: '350582', address: '福建省泉州市晋江市', location: { lng: 118.56388, lat: 24.80951 } },
  675. { name: '泉州市石狮市', adcode: '350581', address: '福建省泉州市石狮市', location: { lng: 118.65021, lat: 24.74517 } },
  676. { name: '漳州市芗城区', adcode: '350602', address: '福建省漳州市芗城区', location: { lng: 117.62588, lat: 24.61406 } },
  677. { name: '漳州市龙文区', adcode: '350603', address: '福建省漳州市龙文区', location: { lng: 117.71692, lat: 24.62148 } },
  678. { name: '漳州市龙海市', adcode: '350681', address: '福建省漳州市龙海市', location: { lng: 117.80713, lat: 24.47173 } },
  679. { name: '漳州市华安县', adcode: '350629', address: '福建省漳州市华安县', location: { lng: 117.4898, lat: 24.7568 } },
  680. { name: '厦门市思明区', adcode: '350203', address: '福建省厦门市思明区', location: { lng: 118.08942, lat: 24.47977 } },
  681. { name: '厦门市湖里区', adcode: '350206', address: '福建省厦门市湖里区', location: { lng: 118.14991, lat: 24.52864 } },
  682. { name: '厦门市同安区', adcode: '350212', address: '福建省厦门市同安区', location: { lng: 118.16728, lat: 24.72657 } },
  683. { name: '厦门市翔安区', adcode: '350213', address: '福建省厦门市翔安区', location: { lng: 118.26543, lat: 24.63788 } },
  684. { name: '厦门市集美区', adcode: '350211', address: '福建省厦门市集美区', location: { lng: 118.09767, lat: 24.60224 } },
  685. { name: '厦门市海沧区', adcode: '350205', address: '福建省厦门市海沧区', location: { lng: 117.97257, lat: 24.53645 } },
  686. { name: '龙岩市漳平市', adcode: '350881', address: '福建省龙岩市漳平市', location: { lng: 117.45528, lat: 25.36948 } },
  687. { name: '三明市大田县', adcode: '350425', address: '福建省三明市大田县', location: { lng: 117.83028, lat: 25.69053 } },
  688. { name: '福州市鼓楼区', adcode: '350102', address: '福建省福州市鼓楼区', location: { lng: 119.29648, lat: 26.08744 } },
  689. { name: '福州市台江区', adcode: '350103', address: '福建省福州市台江区', location: { lng: 119.30476, lat: 26.05936 } },
  690. { name: '福州市仓山区', adcode: '350104', address: '福建省福州市仓山区', location: { lng: 119.27744, lat: 26.00716 } },
  691. { name: '信阳市固始县', adcode: '411525', address: '河南省信阳市固始县', location: { lng: 115.68042, lat: 32.14447 } },
  692. { name: '六安市寿县', adcode: '341521', address: '安徽省六安市寿县', location: { lng: 116.68658, lat: 32.57654 } },
  693. { name: '六安市霍邱县', adcode: '341522', address: '安徽省六安市霍邱县', location: { lng: 116.10428, lat: 32.38912 } },
  694. { name: '六安市李集', adcode: '341522', address: '安徽省六安市霍邱县李集镇', location: { lng: 115.92136, lat: 32.38456 } },
  695. { name: '长沙市望城区', adcode: '430112', address: '湖南省长沙市望城区', location: { lng: 112.83488, lat: 28.31353 } },
  696. { name: '广州市增城区', adcode: '440118', address: '广东省广州市增城区', location: { lng: 113.86262, lat: 23.23745 } },
  697. { name: '吉安市永新县', adcode: '360830', address: '江西省吉安市永新县', location: { lng: 114.22658, lat: 26.90836 } },
  698. { name: '丽水市青田县', adcode: '331121', address: '浙江省丽水市青田县', location: { lng: 120.28158, lat: 28.19935 } },
  699. { name: '温州市泰顺县', adcode: '330329', address: '浙江省温州市泰顺县', location: { lng: 119.75248, lat: 27.67386 } },
  700. { name: '温州市文成县', adcode: '330328', address: '浙江省温州市文成县', location: { lng: 120.08258, lat: 27.68862 } },
  701. { name: '杭州市临安区', adcode: '330112', address: '浙江省杭州市临安区', location: { lng: 119.72768, lat: 30.23397 } },
  702. { name: '金华市兰溪市', adcode: '330781', address: '浙江省金华市兰溪市', location: { lng: 119.48938, lat: 29.29735 } },
  703. { name: '衢州市衢江区', adcode: '330803', address: '浙江省衢州市衢江区', location: { lng: 118.88088, lat: 28.93562 } },
  704. { name: '衢州市开化县', adcode: '330824', address: '浙江省衢州市开化县', location: { lng: 118.30888, lat: 29.29412 } },
  705. { name: '长春市农安县', adcode: '220122', address: '吉林省长春市农安县', location: { lng: 125.16718, lat: 44.45183 } },
  706. { name: '新北市三重区', adcode: '710101', address: '台湾省新北市三重区', location: { lng: 121.48888, lat: 25.05722 } },
  707. { name: '台北市', adcode: '710100', address: '台湾省台北市', location: { lng: 121.50906, lat: 25.04433 } },
  708. { name: '云林县', adcode: '710600', address: '台湾省云林县', location: { lng: 120.43758, lat: 23.71465 } },
  709. { name: '彰化县', adcode: '710500', address: '台湾省彰化县', location: { lng: 120.54869, lat: 24.08279 } },
  710. { name: '台中市清水区', adcode: '710206', address: '台湾省台中市清水区', location: { lng: 120.56496, lat: 24.26271 } },
  711. { name: '菲律宾马尼拉', adcode: 'PH001', address: '菲律宾马尼拉', location: { lng: 120.9842, lat: 14.5995 } },
  712. { name: '北京市', adcode: '110000', address: '北京市', location: { lng: 116.407394, lat: 39.904211 } },
  713. { name: '上海市', adcode: '310000', address: '上海市', location: { lng: 121.473701, lat: 31.230416 } },
  714. { name: '广州市', adcode: '440100', address: '广东省广州市', location: { lng: 113.264385, lat: 23.12911 } },
  715. { name: '深圳市', adcode: '440300', address: '广东省深圳市', location: { lng: 114.057964, lat: 22.543099 } },
  716. { name: '杭州市', adcode: '330100', address: '浙江省杭州市', location: { lng: 120.197855, lat: 30.274084 } },
  717. { name: '南京市', adcode: '320100', address: '江苏省南京市', location: { lng: 118.796875, lat: 32.060255 } },
  718. { name: '成都市', adcode: '510100', address: '四川省成都市', location: { lng: 104.066801, lat: 30.572816 } },
  719. { name: '武汉市', adcode: '420100', address: '湖北省武汉市', location: { lng: 114.287924, lat: 30.592855 } }
  720. ];
  721. const filteredData = localData.filter(item => item.name.includes(keyword) || item.address.includes(keyword));
  722. if (filteredData.length > 0) {
  723. renderRegionSuggestions(filteredData);
  724. } else {
  725. suggestionsDiv.innerHTML = '<div class="alert alert-warning">未找到匹配的区域,请尝试其他关键词</div>';
  726. suggestionsDiv.style.display = 'block';
  727. }
  728. }
  729. function renderRegionSuggestions(pois) {
  730. const suggestionsDiv = document.getElementById('region-suggestions');
  731. let html = '<div class="list-group" style="max-height: 300px; overflow-y: auto;">';
  732. pois.slice(0, 8).forEach(poi => {
  733. const lng = poi.location.lng || poi.location[0];
  734. const lat = poi.location.lat || poi.location[1];
  735. html += '<button type="button" class="list-group-item list-group-item-action" onclick="selectRegion(\'' + poi.name + '\', ' + lng + ', ' + lat + ')">' +
  736. '<div class="d-flex justify-content-between">' +
  737. '<span style="font-weight: 500;">' + poi.name + '</span>' +
  738. '<span class="text-muted text-sm">' + poi.adcode + '</span>' +
  739. '</div>' +
  740. (poi.address ? '<div class="text-sm text-muted">' + poi.address + '</div>' : '') +
  741. '</button>';
  742. });
  743. html += '</div>';
  744. suggestionsDiv.innerHTML = html;
  745. suggestionsDiv.style.display = 'block';
  746. }
  747. function toggleNewSurnameField(surnameType) {
  748. const container = document.getElementById('new-surname-container');
  749. if (surnameType == 1) {
  750. container.style.display = 'block';
  751. } else {
  752. container.style.display = 'none';
  753. document.getElementById('settlement-new-surname').value = '';
  754. }
  755. }
  756. function getSurnameTypeText(type) {
  757. return type == 1 ? '改姓' : '留姓';
  758. }
  759. function selectRegion(name, lng, lat) {
  760. document.getElementById('settlement-region').value = name;
  761. document.getElementById('settlement-latitude').value = lat;
  762. document.getElementById('settlement-longitude').value = lng;
  763. document.getElementById('region-suggestions').style.display = 'none';
  764. const infoText = document.getElementById('location-info-text');
  765. infoText.innerHTML = '已选择区域:' + name + ',坐标:' + lat.toFixed(6) + ', ' + lng.toFixed(6);
  766. document.getElementById('location-info').style.display = 'block';
  767. }
  768. let tooltipDiv = null;
  769. function showMarkerTooltip(e, settlement) {
  770. if (!tooltipDiv) {
  771. tooltipDiv = document.createElement('div');
  772. tooltipDiv.style.cssText = 'position: fixed; background: rgba(30, 41, 59, 0.95); color: white; padding: 12px; border-radius: 8px; min-width: 200px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); z-index: 1000; pointer-events: none; font-size: 13px;';
  773. document.body.appendChild(tooltipDiv);
  774. }
  775. const surnameType = getSurnameTypeText(settlement.surname_type);
  776. tooltipDiv.innerHTML = '<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">' + settlement.name + '</div>' +
  777. (settlement.region ? '<div style="margin-bottom: 4px; color: #94a3b8;">区域:' + settlement.region + '</div>' : '') +
  778. '<div style="margin-bottom: 4px; color: #94a3b8;">人数:' + (settlement.population || 0) + ' 人</div>' +
  779. (settlement.representative_name ? '<div style="margin-bottom: 4px; color: #94a3b8;">代表:' + settlement.representative_name + '</div>' : '') +
  780. '<div style="margin-bottom: 4px; color: #94a3b8;">姓氏:' + surnameType + (settlement.new_surname ? '(改后:' + settlement.new_surname + ')' : '') + '</div>' +
  781. (settlement.description ? '<div style="margin-bottom: 4px; color: #94a3b8; max-width: 220px; word-break: break-all;">备注:' + settlement.description + '</div>' : '') +
  782. (window.isSuperAdmin && settlement.enthusiastic_members ? '<div style="margin-bottom: 4px; color: #fbbf24;">热心宗亲:' + settlement.enthusiastic_members + '</div>' : '') +
  783. '<div style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px; margin-top: 4px;">' +
  784. '<button onclick="event.stopPropagation(); showSettlementDetail(' + JSON.stringify(settlement).replace(/"/g, '&quot;') + '); hideMarkerTooltip();" style="background: #3B82F6; color: white; border: none; padding: 4px 12px; border-radius: 4px; font-size: 11px; cursor: pointer;">查看详情</button>' +
  785. '</div>';
  786. tooltipDiv.style.display = 'block';
  787. // 定位:AMap.Marker 的 mouseover 事件提供 originEvent(原生 MouseEvent),
  788. // 直接用 clientX/Y 定位;AMap.Circle 提供 e.pixel(相对地图容器的像素坐标)作为兜底
  789. let x, y;
  790. if (e.originEvent && e.originEvent.clientX != null) {
  791. x = e.originEvent.clientX;
  792. y = e.originEvent.clientY;
  793. } else if (e.pixel) {
  794. const containerRect = document.getElementById('map-container').getBoundingClientRect();
  795. x = containerRect.left + e.pixel.x;
  796. y = containerRect.top + e.pixel.y;
  797. } else {
  798. return;
  799. }
  800. const vw = window.innerWidth;
  801. const th = tooltipDiv.offsetHeight || 120;
  802. const tw = tooltipDiv.offsetWidth || 220;
  803. // 右侧放不下时改为左侧
  804. tooltipDiv.style.left = (x + 12 + tw > vw ? x - tw - 12 : x + 12) + 'px';
  805. tooltipDiv.style.top = Math.max(8, y - th / 2) + 'px';
  806. }
  807. function hideMarkerTooltip() {
  808. if (tooltipDiv) {
  809. tooltipDiv.style.display = 'none';
  810. }
  811. }
  812. </script>
  813. {% endblock %}