settlements.html 46 KB

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