Карточки заявок обновляются сами: отпечаток состояния, перерисовка без ручного обновления, индикатор «обновляется автоматически»

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-09-25 16:07:11 +03:00
parent 27c350a087
commit a0f6ce9f7b
4 changed files with 85 additions and 8 deletions
+48 -4
View File
@@ -11,7 +11,9 @@ package lkgateway
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"hash/fnv"
"net/http" "net/http"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -22,6 +24,48 @@ type LiveStatus struct {
Text string `json:"text"` // чего ждём / чем закончилось Text string `json:"text"` // чего ждём / чем закончилось
Kind string `json:"kind"` // wait | warn | ok | bad Kind string `json:"kind"` // wait | warn | ok | bad
Active bool `json:"active"` // в работе: страница продолжает опрашивать 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 по всем сделкам одним обходом входящего // form16Snapshot — черновики и подачи поручений 16 по всем сделкам одним обходом входящего
@@ -49,8 +93,8 @@ func form16Snapshot() map[string]*OutgoingDeal {
return m return m
} }
// liveOutgoing — состояние исходящего перевода: сделка M2M + депозитарное поручение 16. // liveOutgoingState — состояние исходящего перевода: сделка M2M + депозитарное поручение 16.
func liveOutgoing(c ClaimView) LiveStatus { func liveOutgoingState(c ClaimView) LiveStatus {
guid := string(c.M2MGUID) guid := string(c.M2MGUID)
var depo *OutgoingDeal var depo *OutgoingDeal
if guid != "" && c.Status == "confirmed" { // каталог нужен только на этапе депо-исполнения if guid != "" && c.Status == "confirmed" { // каталог нужен только на этапе депо-исполнения
@@ -110,8 +154,8 @@ func liveOutgoing(c ClaimView) LiveStatus {
return LiveStatus{Text: russianState(c.Status), Kind: "wait"} return LiveStatus{Text: russianState(c.Status), Kind: "wait"}
} }
// liveIncoming — состояние входящего перевода: наше решение, затем поручение 16/1. // liveIncomingState — состояние входящего перевода: наше решение, затем поручение 16/1.
func liveIncoming(it *IncomingTransfer) LiveStatus { func liveIncomingState(it *IncomingTransfer) LiveStatus {
if it == nil { if it == nil {
return LiveStatus{} return LiveStatus{}
} }
@@ -35,7 +35,7 @@
<b>Перевод не доставлен в НРД</b> — {{sendFailText .}}.<br> <b>Перевод не доставлен в НРД</b> — {{sendFailText .}}.<br>
<span class="muted">Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет ИШ {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. Когда НРД снова будет доступен, нажмите «Отправить повторно» — уйдёт тот же запрос с тем же GUID.</span> <span class="muted">Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет ИШ {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. Когда НРД снова будет доступен, нажмите «Отправить повторно» — уйдёт тот же запрос с тем же GUID.</span>
</div>{{end}} </div>{{end}}
{{$l := liveOut .Claim}}<div class="live live-big live-{{$l.Kind}}" data-live="out:{{.Claim.ID}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div> {{$l := liveOut .Claim}}<div class="live live-big live-{{$l.Kind}}" data-live="out:{{.Claim.ID}}" data-sig="{{$l.Sig}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div>
<table> <table>
<tr><td style="width:200px" class="muted">Создана</td><td>{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}</td></tr> <tr><td style="width:200px" class="muted">Создана</td><td>{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
<tr><td class="muted">Обновлена</td><td>{{.Claim.UpdatedAt.Format "02.01.2006 15:04:05"}}</td></tr> <tr><td class="muted">Обновлена</td><td>{{.Claim.UpdatedAt.Format "02.01.2006 15:04:05"}}</td></tr>
@@ -21,7 +21,7 @@
<h2>Входящий перевод <code>{{short 8 .It.GUID}}</code> <h2>Входящий перевод <code>{{short 8 .It.GUID}}</code>
{{if $answered}}<span class="badge confirmed">отвечено</span>{{else}}<span class="badge awaiting_decision">ждёт решения</span>{{end}} {{if $answered}}<span class="badge confirmed">отвечено</span>{{else}}<span class="badge awaiting_decision">ждёт решения</span>{{end}}
</h2> </h2>
{{$l := liveIn .It}}<div class="live live-big live-{{$l.Kind}}" data-live="in:{{.It.ID}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div> {{$l := liveIn .It}}<div class="live live-big live-{{$l.Kind}}" data-live="in:{{.It.ID}}" data-sig="{{$l.Sig}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div>
<table> <table>
<tr><td style="width:220px" class="muted">Запрос сформирован (отправитель)</td><td>{{msk .It.RequestCreated}}</td></tr> <tr><td style="width:220px" class="muted">Запрос сформирован (отправитель)</td><td>{{msk .It.RequestCreated}}</td></tr>
<tr><td class="muted">Забрали из шлюза (получено нами)</td><td>{{msk .It.ReceivedAt}}{{if not .It.RequestCreated.IsZero}} <span class="muted" style="font-size:12px">· прохождение: {{transit .It.RequestCreated .It.ReceivedAt}}</span>{{end}}</td></tr> <tr><td class="muted">Забрали из шлюза (получено нами)</td><td>{{msk .It.ReceivedAt}}{{if not .It.RequestCreated.IsZero}} <span class="muted" style="font-size:12px">· прохождение: {{transit .It.RequestCreated .It.ReceivedAt}}</span>{{end}}</td></tr>
+35 -2
View File
@@ -158,6 +158,12 @@ tbody tr:hover td { background:var(--accent-weak); }
.live-bad { color:var(--err) } .live-bad::before { background:var(--err) } .live-bad { color:var(--err) } .live-bad::before { background:var(--err) }
.live-big { font-size:14px; margin:-6px 0 12px; font-weight:500 } .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[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.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.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); } .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(); poll();
setInterval(poll, 12000); // каждые 12 сек setInterval(poll, 12000); // каждые 12 сек
// Онлайн-статус заявок в работе: строки с data-live и data-active опрашиваются, пока ждут. // Онлайн-статус заявок в работе: строки с data-live и data-active опрашиваются, пока ждут.
// Карточка заявки (live-big с data-sig): изменилось что-то кроме строки статуса — страница
// перерисовывается сама; если оператор уже начал заполнять форму — только предложение обновить.
(function(){ (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') ? '<span class="dot"></span>обновляется автоматически' : 'заявка завершена — автообновление не требуется');
});
function tick(){ function tick(){
var els = document.querySelectorAll('.live[data-live][data-active]'); var els = document.querySelectorAll('.live[data-live][data-active]');
if (!els.length) return; if (!els.length) return;
@@ -561,11 +582,23 @@ label { font-size:13px; font-weight:500; }
els.forEach(function(e){ els.forEach(function(e){
var k = e.getAttribute('data-live').split(':'), st = (d[k[0]]||{})[k[1]]; var k = e.getAttribute('data-live').split(':'), st = (d[k[0]]||{})[k[1]];
if (!st) return; 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, 'Заявка изменилась. <a href="' + location.pathname + location.search + '">Обновить страницу</a> (отмеченное в форме не сохранится)', 'changed');
e.setAttribute('data-sig', st.sig);
}
e.textContent = st.text; 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'); if (!st.active) e.removeAttribute('data-active');
var nx = e.nextElementSibling;
if (big && !(nx && nx.classList.contains('changed'))) {
meta(e, st.active ? '<span class="dot"></span>обновляется автоматически · проверено в ' + hhmmss() : 'заявка завершена — автообновление не требуется');
}
}); });
}).catch(function(){}); }).catch(function(){
document.querySelectorAll('.live-big[data-live][data-active]').forEach(function(e){ meta(e, 'нет связи с bj — статус не обновляется, повторим через несколько секунд', 'lost'); });
});
} }
setInterval(tick, 8000); setInterval(tick, 8000);
if (document.querySelector('.live[data-live][data-active]')) setTimeout(tick, 1500); if (document.querySelector('.live[data-live][data-active]')) setTimeout(tick, 1500);