diff --git a/internal/lkgateway/livestatus.go b/internal/lkgateway/livestatus.go
index e5ca730..4036de4 100644
--- a/internal/lkgateway/livestatus.go
+++ b/internal/lkgateway/livestatus.go
@@ -11,7 +11,9 @@ package lkgateway
import (
"context"
"encoding/json"
+ "hash/fnv"
"net/http"
+ "strconv"
"strings"
"sync"
"time"
@@ -22,6 +24,48 @@ type LiveStatus struct {
Text string `json:"text"` // чего ждём / чем закончилось
Kind string `json:"kind"` // wait | warn | ok | bad
Active bool `json:"active"` // в работе: страница продолжает опрашивать
+ Sig string `json:"sig,omitempty"` // отпечаток состояния: изменился — карточка перерисовывается
+}
+
+// liveSig — короткий отпечаток всего, что показывает карточка: сменился — страница обновляется.
+func liveSig(parts ...any) string {
+ raw, _ := json.Marshal(parts)
+ h := fnv.New64a()
+ _, _ = h.Write(raw)
+ return strconv.FormatUint(h.Sum64(), 36)
+}
+
+// liveOutgoing — состояние исходящего перевода с отпечатком карточки.
+func liveOutgoing(c ClaimView) LiveStatus {
+ st := liveOutgoingState(c)
+ guid := string(c.M2MGUID)
+ var quik []*QuikLink
+ var depoL *DepoLink
+ var watch *OrderWatch
+ if guid != "" {
+ quik = globalQuikLinks.ForGUID(guid)
+ if globalDepoLinks != nil {
+ depoL = globalDepoLinks.Get(guid)
+ }
+ if globalOrderWatch != nil {
+ watch = globalOrderWatch.Get(guid)
+ }
+ }
+ st.Sig = liveSig(st.Text, c.Status, c.UpdatedAt, sendFailGet(guid), quik, depoL, watch)
+ return st
+}
+
+// liveIncoming — состояние входящего перевода с отпечатком карточки.
+func liveIncoming(it *IncomingTransfer) LiveStatus {
+ st := liveIncomingState(it)
+ if it != nil {
+ var watch *OrderWatch
+ if globalOrderWatch != nil {
+ watch = globalOrderWatch.Get(it.GUID)
+ }
+ st.Sig = liveSig(st.Text, it, watch)
+ }
+ return st
}
// form16Snapshot — черновики и подачи поручений 16 по всем сделкам одним обходом входящего
@@ -49,8 +93,8 @@ func form16Snapshot() map[string]*OutgoingDeal {
return m
}
-// liveOutgoing — состояние исходящего перевода: сделка M2M + депозитарное поручение 16.
-func liveOutgoing(c ClaimView) LiveStatus {
+// liveOutgoingState — состояние исходящего перевода: сделка M2M + депозитарное поручение 16.
+func liveOutgoingState(c ClaimView) LiveStatus {
guid := string(c.M2MGUID)
var depo *OutgoingDeal
if guid != "" && c.Status == "confirmed" { // каталог нужен только на этапе депо-исполнения
@@ -110,8 +154,8 @@ func liveOutgoing(c ClaimView) LiveStatus {
return LiveStatus{Text: russianState(c.Status), Kind: "wait"}
}
-// liveIncoming — состояние входящего перевода: наше решение, затем поручение 16/1.
-func liveIncoming(it *IncomingTransfer) LiveStatus {
+// liveIncomingState — состояние входящего перевода: наше решение, затем поручение 16/1.
+func liveIncomingState(it *IncomingTransfer) LiveStatus {
if it == nil {
return LiveStatus{}
}
diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html
index 6919c59..cbd3f83 100644
--- a/internal/lkgateway/web/templates/admin_claim.html
+++ b/internal/lkgateway/web/templates/admin_claim.html
@@ -35,7 +35,7 @@
Перевод не доставлен в НРД — {{sendFailText .}}.
Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет ИШ {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. Когда НРД снова будет доступен, нажмите «Отправить повторно» — уйдёт тот же запрос с тем же GUID.
{{end}}
- {{$l := liveOut .Claim}}
{{$l.Text}}
+ {{$l := liveOut .Claim}}{{$l.Text}}
| Создана | {{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}} |
| Обновлена | {{.Claim.UpdatedAt.Format "02.01.2006 15:04:05"}} |
diff --git a/internal/lkgateway/web/templates/admin_incoming_detail.html b/internal/lkgateway/web/templates/admin_incoming_detail.html
index c9ace58..d8a85c3 100644
--- a/internal/lkgateway/web/templates/admin_incoming_detail.html
+++ b/internal/lkgateway/web/templates/admin_incoming_detail.html
@@ -21,7 +21,7 @@
Входящий перевод {{short 8 .It.GUID}}
{{if $answered}}отвечено{{else}}ждёт решения{{end}}
- {{$l := liveIn .It}}{{$l.Text}}
+ {{$l := liveIn .It}}{{$l.Text}}
| Запрос сформирован (отправитель) | {{msk .It.RequestCreated}} |
| Забрали из шлюза (получено нами) | {{msk .It.ReceivedAt}}{{if not .It.RequestCreated.IsZero}} · прохождение: {{transit .It.RequestCreated .It.ReceivedAt}}{{end}} |
diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html
index eae68fd..738fe3b 100644
--- a/internal/lkgateway/web/templates/layout.html
+++ b/internal/lkgateway/web/templates/layout.html
@@ -158,6 +158,12 @@ tbody tr:hover td { background:var(--accent-weak); }
.live-bad { color:var(--err) } .live-bad::before { background:var(--err) }
.live-big { font-size:14px; margin:-6px 0 12px; font-weight:500 }
.live[data-active] { color:var(--text-2) } .live-warn[data-active] { color:var(--warn) }
+.live-meta { font-size:12px; color:var(--muted); margin:2px 0 10px; display:flex; align-items:center; gap:6px; }
+.live-meta .dot { width:7px; height:7px; border-radius:50%; background:var(--ok,#2e7d32); display:inline-block; animation:livedot 2s ease-in-out infinite; }
+.live-meta.changed { color:var(--warn,#8a5a00); }
+.live-meta.lost { color:var(--err,#b3261e); }
+@keyframes livedot { 50% { opacity:.3 } }
+@media (prefers-reduced-motion: reduce) { .live-meta .dot { animation:none } }
.badge.draft, .badge.validated, .badge.submitted_to_nsd { background:var(--accent-weak); color:var(--accent); }
.badge.awaiting_decision, .badge.manual_approval { background:var(--warn-weak); color:var(--warn); }
.badge.confirmed, .badge.awaiting_sub16, .badge.done { background:var(--ok-weak); color:var(--ok); }
@@ -550,7 +556,22 @@ label { font-size:13px; font-weight:500; }
poll();
setInterval(poll, 12000); // каждые 12 сек
// Онлайн-статус заявок в работе: строки с data-live и data-active опрашиваются, пока ждут.
+ // Карточка заявки (live-big с data-sig): изменилось что-то кроме строки статуса — страница
+ // перерисовывается сама; если оператор уже начал заполнять форму — только предложение обновить.
(function(){
+ var dirty = false;
+ document.addEventListener('input', function(e){ if (e.target.closest('main form')) dirty = true; }, true);
+ document.addEventListener('change', function(e){ if (e.target.closest('main form')) dirty = true; }, true);
+ try { var u = new URL(location.href); if (u.searchParams.has('flash')) { u.searchParams.delete('flash'); history.replaceState(null, '', u.pathname + u.search + u.hash); } } catch(e){}
+ function hhmmss(){ var d = new Date(); return ('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)+':'+('0'+d.getSeconds()).slice(-2); }
+ function meta(e, text, cls){
+ var m = e.nextElementSibling;
+ if (!m || !m.classList.contains('live-meta')) { m = document.createElement('div'); m.className = 'live-meta'; e.parentNode.insertBefore(m, e.nextSibling); }
+ m.className = 'live-meta' + (cls ? ' ' + cls : ''); m.innerHTML = text;
+ }
+ document.querySelectorAll('.live-big[data-live]').forEach(function(e){
+ meta(e, e.hasAttribute('data-active') ? 'обновляется автоматически' : 'заявка завершена — автообновление не требуется');
+ });
function tick(){
var els = document.querySelectorAll('.live[data-live][data-active]');
if (!els.length) return;
@@ -561,11 +582,23 @@ label { font-size:13px; font-weight:500; }
els.forEach(function(e){
var k = e.getAttribute('data-live').split(':'), st = (d[k[0]]||{})[k[1]];
if (!st) return;
+ var big = e.classList.contains('live-big'), was = e.getAttribute('data-sig');
+ if (big && was && st.sig && st.sig !== was) {
+ if (!dirty) { location.reload(); return; }
+ meta(e, 'Заявка изменилась. Обновить страницу (отмеченное в форме не сохранится)', 'changed');
+ e.setAttribute('data-sig', st.sig);
+ }
e.textContent = st.text;
- e.className = 'live ' + (e.classList.contains('live-big') ? 'live-big ' : '') + 'live-' + st.kind;
+ e.className = 'live ' + (big ? 'live-big ' : '') + 'live-' + st.kind;
if (!st.active) e.removeAttribute('data-active');
+ var nx = e.nextElementSibling;
+ if (big && !(nx && nx.classList.contains('changed'))) {
+ meta(e, st.active ? 'обновляется автоматически · проверено в ' + hhmmss() : 'заявка завершена — автообновление не требуется');
+ }
});
- }).catch(function(){});
+ }).catch(function(){
+ document.querySelectorAll('.live-big[data-live][data-active]').forEach(function(e){ meta(e, 'нет связи с bj — статус не обновляется, повторим через несколько секунд', 'lost'); });
+ });
}
setInterval(tick, 8000);
if (document.querySelector('.live[data-live][data-active]')) setTimeout(tick, 1500);