index.html 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. {% extends "layout.html" %}
  2. {% block title %}上传管理 - 家谱管理系统{% endblock %}
  3. {% block extra_css %}
  4. <style>
  5. .thumbnail-img {
  6. width: 50px;
  7. height: 70px;
  8. object-fit: cover;
  9. border-radius: 4px;
  10. cursor: pointer;
  11. border: 1px solid #dee2e6;
  12. transition: transform 0.2s;
  13. background-color: #f8f9fa;
  14. }
  15. .thumbnail-img:hover {
  16. transform: scale(1.5);
  17. box-shadow: 0 4px 8px rgba(0,0,0,0.15);
  18. z-index: 10;
  19. position: relative;
  20. }
  21. /* Modal Image Viewer Styles */
  22. .modal-image-container {
  23. height: 85vh;
  24. display: flex;
  25. flex-direction: column;
  26. }
  27. .image-toolbar {
  28. background: #e9ecef;
  29. padding: 5px 10px;
  30. border-bottom: 1px solid #dee2e6;
  31. display: flex;
  32. gap: 10px;
  33. align-items: center;
  34. flex-wrap: wrap;
  35. }
  36. .image-viewer {
  37. flex: 1;
  38. border: 1px solid #ccc;
  39. background: #f0f0f0;
  40. overflow: hidden;
  41. text-align: center;
  42. position: relative;
  43. cursor: grab;
  44. user-select: none;
  45. }
  46. .image-viewer:active {
  47. cursor: grabbing;
  48. }
  49. .image-wrapper {
  50. display: inline-block;
  51. transition: transform 0.2s ease-out;
  52. transform-origin: center center;
  53. position: absolute;
  54. top: 50%;
  55. left: 50%;
  56. transform: translate(-50%, -50%);
  57. }
  58. .image-wrapper img {
  59. max-width: 100%;
  60. max-height: 100%;
  61. display: block;
  62. pointer-events: none;
  63. user-select: none;
  64. transition: filter 0.2s;
  65. box-shadow: 0 0 20px rgba(0,0,0,0.1);
  66. }
  67. .magnifier-glass {
  68. position: absolute;
  69. border: 3px solid #000;
  70. border-radius: 50%;
  71. cursor: none;
  72. width: 150px;
  73. height: 150px;
  74. box-shadow: 0 0 10px rgba(0,0,0,0.5);
  75. display: none;
  76. z-index: 1000;
  77. background-repeat: no-repeat;
  78. background-color: white;
  79. pointer-events: none;
  80. }
  81. </style>
  82. {% endblock %}
  83. {% block content %}
  84. <div class="d-flex justify-content-between align-items-center mb-4">
  85. <h2><i class="bi bi-file-earmark-arrow-up"></i> 家谱扫描件管理</h2>
  86. <a href="{{ url_for('upload') }}" class="btn btn-primary">
  87. <i class="bi bi-cloud-upload me-1"></i> 上传新扫描件
  88. </a>
  89. </div>
  90. <div class="card shadow-sm mb-4">
  91. <div class="card-body">
  92. <form method="GET" action="{{ url_for('index') }}" class="row g-3 align-items-end">
  93. <div class="col-md-2">
  94. <label class="form-label">版本名称</label>
  95. <input type="text" name="version" class="form-control" value="{{ version }}" placeholder="模糊搜索">
  96. </div>
  97. <div class="col-md-2">
  98. <label class="form-label">版本来源</label>
  99. <input type="text" name="source" class="form-control" value="{{ source }}" placeholder="模糊搜索">
  100. </div>
  101. <div class="col-md-2">
  102. <label class="form-label">提供人</label>
  103. <input type="text" name="person" class="form-control" value="{{ person }}" placeholder="模糊搜索">
  104. </div>
  105. <div class="col-md-2">
  106. <label class="form-label">文件类型</label>
  107. <select name="file_type" class="form-select">
  108. <option value="">全部</option>
  109. <option value="图片" {% if file_type == '图片' %}selected{% endif %}>图片</option>
  110. <option value="PDF" {% if file_type == 'PDF' %}selected{% endif %}>PDF</option>
  111. </select>
  112. </div>
  113. <div class="col-md-4">
  114. <button type="submit" class="btn btn-primary"><i class="bi bi-search"></i> 搜索</button>
  115. <a href="{{ url_for('index') }}" class="btn btn-outline-secondary">重置</a>
  116. </div>
  117. </form>
  118. </div>
  119. </div>
  120. <div class="card shadow-sm">
  121. <div class="card-body p-0">
  122. <div class="table-responsive">
  123. <table class="table table-hover mb-0">
  124. <thead class="table-light">
  125. <tr>
  126. <th class="px-4">文件名</th>
  127. <th>页码</th>
  128. <th>提供人</th>
  129. <th>AI 解析状态</th>
  130. <th>上传时间</th>
  131. <th class="text-center">操作</th>
  132. </tr>
  133. </thead>
  134. <tbody>
  135. {% for record in records %}
  136. <tr>
  137. <td class="px-4">
  138. <div class="d-flex align-items-center">
  139. <img src="{{ record.oss_url }}" class="thumbnail-img me-3" onclick="openImageViewer('{{ record.oss_url }}', {{ record.page_number }})" title="点击预览">
  140. <div class="text-break fw-bold">
  141. {% if record.file_type == 'PDF' %}
  142. <span class="badge bg-danger me-1">PDF</span>
  143. {% elif record.file_type == '图片' %}
  144. <span class="badge bg-success me-1">图片</span>
  145. {% endif %}
  146. {{ record.file_name }}
  147. </div>
  148. </div>
  149. </td>
  150. <td>
  151. {% if record.page_number %}
  152. <span class="badge bg-info text-dark">第 {{ record.page_number }} 页</span>
  153. {% else %}
  154. <span class="text-muted">未知</span>
  155. {% endif %}
  156. </td>
  157. <td>
  158. <div class="fw-bold">{{ record.upload_person or '未知' }}</div>
  159. {% if record.genealogy_version or record.genealogy_source %}
  160. <div class="small text-muted mt-1" style="font-size: 0.75rem;">
  161. {% if record.genealogy_version %}
  162. <span title="版本名称"><i class="bi bi-tag"></i> {{ record.genealogy_version }}</span><br>
  163. {% endif %}
  164. {% if record.genealogy_source %}
  165. <span title="版本来源"><i class="bi bi-archive"></i> {{ record.genealogy_source }}</span>
  166. {% endif %}
  167. </div>
  168. {% endif %}
  169. </td>
  170. <td>
  171. {% if record.ai_status == 2 %}
  172. <span class="badge bg-success"><i class="bi bi-check-circle"></i> 解析成功</span>
  173. {% elif record.ai_status == 1 %}
  174. <span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split"></i> 解析中...</span>
  175. {% elif record.ai_status == 3 %}
  176. <span class="badge bg-danger"><i class="bi bi-x-circle"></i> 解析失败</span>
  177. {% else %}
  178. <span class="badge bg-secondary">未解析</span>
  179. {% endif %}
  180. </td>
  181. <td>{{ record.upload_time }}</td>
  182. <td class="text-center">
  183. <button onclick="openImageViewer('{{ record.oss_url }}', {{ record.page_number }})" class="btn btn-sm btn-outline-info" title="查看原图">
  184. <i class="bi bi-eye"></i>
  185. </button>
  186. {% if record.ai_status == 2 %}
  187. <a href="{{ url_for('add_member', record_id=record.id) }}" class="btn btn-sm btn-success" title="查看解析并录入">
  188. <i class="bi bi-magic"></i> 录入
  189. </a>
  190. <button onclick="reStartAiAnalysis({{ record.id }})" class="btn btn-sm btn-outline-warning" title="重新AI解析">
  191. <i class="bi bi-arrow-repeat"></i> 重析
  192. </button>
  193. {% else %}
  194. <button onclick="startAiAnalysis({{ record.id }})" class="btn btn-sm btn-outline-primary" title="AI 解析">
  195. <i class="bi bi-robot"></i> 解析
  196. </button>
  197. <a href="{{ url_for('add_member', record_id=record.id) }}" class="btn btn-sm btn-outline-secondary" title="手动录入">
  198. <i class="bi bi-pencil"></i> 手动
  199. </a>
  200. {% endif %}
  201. <form action="{{ url_for('delete_upload', record_id=record.id) }}" method="POST" class="d-inline" onsubmit="return confirm('确定要删除此文件记录吗?此操作无法撤销。');">
  202. <button type="submit" class="btn btn-sm btn-outline-danger" title="删除">
  203. <i class="bi bi-trash"></i>
  204. </button>
  205. </form>
  206. </td>
  207. </tr>
  208. {% endfor %}
  209. {% if not records %}
  210. <tr>
  211. <td colspan="4" class="text-center py-5 text-muted">
  212. <i class="bi bi-folder-x fs-1 d-block mb-2"></i>
  213. 暂无扫描件数据,请先上传。
  214. </td>
  215. </tr>
  216. {% endif %}
  217. </tbody>
  218. </table>
  219. </div>
  220. </div>
  221. {% if total_pages > 1 or total > 0 %}
  222. <div class="card-footer d-flex justify-content-between align-items-center bg-white border-top-0 pt-3">
  223. <div class="text-muted small">
  224. 共 <strong>{{ total }}</strong> 条记录,当前第 {{ page }} / {{ total_pages if total_pages > 0 else 1 }} 页
  225. </div>
  226. {% if total_pages > 1 %}
  227. <nav>
  228. <ul class="pagination pagination-sm mb-0">
  229. <li class="page-item {% if page == 1 %}disabled{% endif %}">
  230. <a class="page-link" href="{{ url_for('index', page=page-1, version=version, source=source, person=person, file_type=file_type) }}">上一页</a>
  231. </li>
  232. {% set start_page = page - 2 if page - 2 > 0 else 1 %}
  233. {% set end_page = start_page + 4 if start_page + 4 <= total_pages else total_pages %}
  234. {% set start_page = end_page - 4 if end_page - 4 > 0 else 1 %}
  235. {% for p in range(start_page, end_page + 1) %}
  236. <li class="page-item {% if p == page %}active{% endif %}">
  237. <a class="page-link" href="{{ url_for('index', page=p, version=version, source=source, person=person, file_type=file_type) }}">{{ p }}</a>
  238. </li>
  239. {% endfor %}
  240. <li class="page-item {% if page == total_pages %}disabled{% endif %}">
  241. <a class="page-link" href="{{ url_for('index', page=page+1, version=version, source=source, person=person, file_type=file_type) }}">下一页</a>
  242. </li>
  243. </ul>
  244. </nav>
  245. {% endif %}
  246. </div>
  247. {% endif %}
  248. </div>
  249. <!-- Image Viewer Modal -->
  250. <div class="modal fade" id="imageModal" tabindex="-1" aria-hidden="true">
  251. <div class="modal-dialog modal-fullscreen">
  252. <div class="modal-content bg-light">
  253. <div class="modal-header py-2">
  254. <h5 class="modal-title fs-6"><i class="bi bi-image"></i> 扫描件预览 <span id="modalPageNum" class="badge bg-secondary ms-2"></span></h5>
  255. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  256. </div>
  257. <div class="modal-body p-0 modal-image-container">
  258. <div class="image-toolbar">
  259. <div class="btn-group btn-group-sm">
  260. <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(-90)" title="左旋90°"><i class="bi bi-arrow-counterclockwise"></i></button>
  261. <button type="button" class="btn btn-outline-secondary" onclick="rotateImage(90)" title="右旋90°"><i class="bi bi-arrow-clockwise"></i></button>
  262. </div>
  263. <div class="d-flex align-items-center gap-2 mx-3 border-start border-end px-3">
  264. <i class="bi bi-brightness-high" title="亮度"></i>
  265. <input type="range" class="form-range" min="50" max="150" value="100" id="brightnessRange" oninput="updateImageFilter()" style="width: 80px;">
  266. <i class="bi bi-circle-half ms-2" title="对比度"></i>
  267. <input type="range" class="form-range" min="50" max="200" value="100" id="contrastRange" oninput="updateImageFilter()" style="width: 80px;">
  268. <button class="btn btn-link btn-sm text-decoration-none py-0" onclick="resetFilters()">重置</button>
  269. </div>
  270. <div class="form-check form-switch ms-auto mb-0" title="开启后鼠标悬停图片可局部放大">
  271. <input class="form-check-input" type="checkbox" id="magnifierSwitch">
  272. <label class="form-check-label small" for="magnifierSwitch">🔍 放大镜</label>
  273. </div>
  274. </div>
  275. <div class="image-viewer shadow-inner" id="viewer">
  276. <div id="magnifier" class="magnifier-glass"></div>
  277. <div id="imageWrapper" class="image-wrapper">
  278. <img id="refImage" src="" alt="家谱图片" draggable="false">
  279. </div>
  280. </div>
  281. </div>
  282. </div>
  283. </div>
  284. </div>
  285. {% endblock %}
  286. {% block extra_js %}
  287. <script>
  288. function openImageViewer(url, pageNum) {
  289. const modal = new bootstrap.Modal(document.getElementById('imageModal'));
  290. document.getElementById('refImage').src = url;
  291. document.getElementById('modalPageNum').textContent = pageNum ? '第 ' + pageNum + ' 页' : '';
  292. modal.show();
  293. resetFilters();
  294. }
  295. // --- Image Viewer Logic ---
  296. let imgRotation = 0;
  297. let imgBrightness = 100;
  298. let imgContrast = 100;
  299. let isDragging = false;
  300. let hasDragged = false;
  301. let startX = 0, startY = 0;
  302. let currentX = 0, currentY = 0;
  303. let isZoomedIn = false;
  304. const ZOOM_LEVEL = 2.0;
  305. const viewer = document.getElementById('viewer');
  306. const magnifier = document.getElementById('magnifier');
  307. const magnifierSwitch = document.getElementById('magnifierSwitch');
  308. const imageWrapper = document.getElementById('imageWrapper');
  309. const refImage = document.getElementById('refImage');
  310. if (imageWrapper) {
  311. viewer.style.cursor = 'zoom-in';
  312. viewer.addEventListener('mousedown', (e) => {
  313. if (e.target.closest('.image-toolbar') || e.target.closest('.magnifier-glass')) return;
  314. isDragging = true;
  315. hasDragged = false;
  316. startX = e.clientX;
  317. startY = e.clientY;
  318. viewer.style.cursor = 'grabbing';
  319. e.preventDefault();
  320. });
  321. window.addEventListener('mousemove', (e) => {
  322. if (!isDragging) return;
  323. const dx = e.clientX - startX;
  324. const dy = e.clientY - startY;
  325. if (Math.abs(dx) > 2 || Math.abs(dy) > 2) hasDragged = true;
  326. currentX += dx;
  327. currentY += dy;
  328. startX = e.clientX;
  329. startY = e.clientY;
  330. updateImageTransform();
  331. });
  332. window.addEventListener('mouseup', (e) => {
  333. if (isDragging) {
  334. isDragging = false;
  335. viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
  336. if (!hasDragged && viewer.contains(e.target)) toggleZoom();
  337. }
  338. });
  339. }
  340. function toggleZoom() {
  341. isZoomedIn = !isZoomedIn;
  342. if (!isZoomedIn) {
  343. currentX = 0;
  344. currentY = 0;
  345. }
  346. updateImageTransform();
  347. viewer.style.cursor = isZoomedIn ? 'grab' : 'zoom-in';
  348. }
  349. viewer.addEventListener('mousemove', function(e) {
  350. if (!magnifierSwitch.checked || isDragging || !refImage.src) {
  351. magnifier.style.display = 'none';
  352. return;
  353. }
  354. const rect = refImage.getBoundingClientRect();
  355. const x = e.clientX - rect.left;
  356. const y = e.clientY - rect.top;
  357. if (x < 0 || x > rect.width || y < 0 || y > rect.height) {
  358. magnifier.style.display = 'none';
  359. return;
  360. }
  361. magnifier.style.display = 'block';
  362. const glassOffset = 20;
  363. const viewerRect = viewer.getBoundingClientRect();
  364. magnifier.style.left = (e.clientX - viewerRect.left + glassOffset) + 'px';
  365. magnifier.style.top = (e.clientY - viewerRect.top + glassOffset) + 'px';
  366. const zoom = 2.5;
  367. magnifier.style.backgroundImage = `url('${refImage.src}')`;
  368. magnifier.style.backgroundSize = `${rect.width * zoom}px ${rect.height * zoom}px`;
  369. magnifier.style.backgroundPosition = `-${x * zoom - 75}px -${y * zoom - 75}px`;
  370. });
  371. function rotateImage(deg) {
  372. imgRotation = (imgRotation + deg) % 360;
  373. updateImageTransform();
  374. }
  375. function updateImageFilter() {
  376. imgBrightness = document.getElementById('brightnessRange').value;
  377. imgContrast = document.getElementById('contrastRange').value;
  378. applyImageFilters();
  379. }
  380. function resetFilters() {
  381. imgRotation = 0;
  382. imgBrightness = 100;
  383. imgContrast = 100;
  384. currentX = 0;
  385. currentY = 0;
  386. isZoomedIn = false;
  387. document.getElementById('brightnessRange').value = 100;
  388. document.getElementById('contrastRange').value = 100;
  389. if(magnifierSwitch) magnifierSwitch.checked = false;
  390. updateImageTransform();
  391. applyImageFilters();
  392. if(viewer) viewer.style.cursor = 'zoom-in';
  393. }
  394. function updateImageTransform() {
  395. if (imageWrapper) {
  396. const scale = isZoomedIn ? ZOOM_LEVEL : 1;
  397. imageWrapper.style.transform = `translate(calc(-50% + ${currentX}px), calc(-50% + ${currentY}px)) rotate(${imgRotation}deg) scale(${scale})`;
  398. }
  399. }
  400. function applyImageFilters() {
  401. if (refImage) {
  402. refImage.style.filter = `brightness(${imgBrightness}%) contrast(${imgContrast}%)`;
  403. }
  404. }
  405. function startAiAnalysis(recordId) {
  406. if (!confirm('确定要开始 AI 解析吗?这可能需要一些时间。')) return;
  407. // Optimistic UI update
  408. const btn = event.currentTarget;
  409. const originalHtml = btn.innerHTML;
  410. btn.disabled = true;
  411. btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 解析中...';
  412. fetch('/manager/api/start_analysis/' + recordId, {
  413. method: 'POST',
  414. headers: {
  415. 'Content-Type': 'application/json'
  416. }
  417. })
  418. .then(response => response.json())
  419. .then(data => {
  420. if (data.success) {
  421. // Reload to show status update (or update DOM dynamically)
  422. window.location.reload();
  423. } else {
  424. alert('启动解析失败: ' + data.message);
  425. btn.innerHTML = originalHtml;
  426. btn.disabled = false;
  427. }
  428. })
  429. .catch(error => {
  430. console.error('Error:', error);
  431. alert('请求失败,请重试');
  432. btn.innerHTML = originalHtml;
  433. btn.disabled = false;
  434. });
  435. }
  436. function reStartAiAnalysis(recordId) {
  437. if (!confirm('确定要重新进行 AI 解析吗?这将覆盖原有的解析结果。')) return;
  438. const btn = event.currentTarget;
  439. const originalHtml = btn.innerHTML;
  440. btn.disabled = true;
  441. btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 解析中...';
  442. fetch('/manager/api/start_analysis/' + recordId, {
  443. method: 'POST',
  444. headers: {
  445. 'Content-Type': 'application/json'
  446. }
  447. })
  448. .then(response => response.json())
  449. .then(data => {
  450. if (data.success) {
  451. window.location.reload();
  452. } else {
  453. alert('启动解析失败: ' + data.message);
  454. btn.innerHTML = originalHtml;
  455. btn.disabled = false;
  456. }
  457. })
  458. .catch(error => {
  459. console.error('Error:', error);
  460. alert('请求失败,请重试');
  461. btn.innerHTML = originalHtml;
  462. btn.disabled = false;
  463. });
  464. }
  465. </script>
  466. {% endblock %}