From 8f44d6fcb9679cc01a29e62754ec268433c09b10 Mon Sep 17 00:00:00 2001 From: fontvielle Date: Tue, 13 Oct 2026 12:50:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=96=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BC=D0=B5=D0=BD=D0=B0:=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=BF=D0=BE=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D1=85=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BE=D0=BA?= =?UTF-8?q?;=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D1=8B=D0=B3=D0=B0=D0=B5=D1=82=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B2=D0=B5=D1=80=D1=85=20=D0=BF=D1=80=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Журнал: новый эндпоинт /admin/journal/rows (строки новее since, рендер тем же шаблоном journalrow) + JS раз в 8 с подставляет новые заявки сверху с мягкой подсветкой (row-new), без перезагрузки и без дёрганья; счётчик обновляется, плашка «нет переводов» убирается. Таблица теперь рендерится всегда (tbody#journal-body), строка вынесена в {{define journalrow}}. Карточка заявки: автообновление по смене подписи раньше делало location.reload() и сбрасывало прокрутку наверх — мешало читать. Теперь bjReloadKeepScroll() сохраняет позицию прокрутки (sessionStorage) и восстанавливает после перезагрузки; автообновление сохранено. Co-Authored-By: Claude Opus 4.8 --- internal/lkgateway/journal.go | 123 ++++++++++++------ .../web/templates/admin_journal.html | 31 ++--- internal/lkgateway/web/templates/layout.html | 66 +++++++++- 3 files changed, 160 insertions(+), 60 deletions(-) diff --git a/internal/lkgateway/journal.go b/internal/lkgateway/journal.go index 398b3c9..30f3c5e 100644 --- a/internal/lkgateway/journal.go +++ b/internal/lkgateway/journal.go @@ -5,9 +5,13 @@ package lkgateway // страницах). Для быстрого обзора всего потока с контрагентами. import ( + "bytes" + "context" + "encoding/json" "fmt" "net/http" "sort" + "strconv" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" @@ -63,56 +67,91 @@ func incomingStatusOf(it *IncomingTransfer) incomingStatusVM { return incomingStatusVM{Class: c, Text: t, Outcome: o} } +// buildJournalRows — объединённая лента (исходящие + входящие), отсортированная от новых к старым. +func buildJournalRows(ctx context.Context, svc *Service, store *IncomingStore) []journalRow { + var rows []journalRow + + // Исходящие (наши заявки в НРД). + if page, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 100}); err == nil { + for _, c := range page.Items { + party := m2mPartyName(c.ReceivingDepositoryINN) + if party != "" { + party += " · ИНН " + c.ReceivingDepositoryINN + } else { + party = "ИНН " + c.ReceivingDepositoryINN + } + rows = append(rows, journalRow{ + Time: c.CreatedAt, Dir: "Исходящий", DirKind: "out", + Party: party, SecN: len(c.Securities), + Status: c.Status, StatusText: russianState(c.Status), + Href: "/admin/claims/" + c.ID, + LiveKey: "out:" + c.ID, Live: liveOutgoing(c), + }) + } + } + // Входящие (переводы к нам). + for _, it := range store.List() { + party := m2mPartyName(it.SenderCode) + if party == "" { + party = m2mPartyName(it.SenderINN) + } + if party != "" { + party += " · " + it.SenderCode + } else { + party = it.SenderCode + } + cls, txt, outcome := incomingJournalStatus(it) + rows = append(rows, journalRow{ + Time: it.ReceivedAt, Dir: "Входящий", DirKind: "in", + Party: party, SecN: len(it.Securities), + Status: cls, StatusText: txt, Outcome: outcome, + Href: "/admin/incoming/" + it.ID, + LiveKey: "in:" + it.ID, Live: liveIncoming(it), + }) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Time.After(rows[j].Time) }) + return rows +} + // registerJournal вешает /admin/journal — объединённая лента вход+исход. func registerJournal(mux *http.ServeMux, a *admin, svc *Service, store *IncomingStore) { registerLiveStatus(mux, svc, store) // «чего ждёт заявка» для строк в работе mux.HandleFunc("/admin/journal", func(w http.ResponseWriter, r *http.Request) { - var rows []journalRow - - // Исходящие (наши заявки в НРД). - if page, err := svc.ListClaims(r.Context(), m2mcore.Filter{Limit: 100}); err == nil { - for _, c := range page.Items { - party := m2mPartyName(c.ReceivingDepositoryINN) - if party != "" { - party += " · ИНН " + c.ReceivingDepositoryINN - } else { - party = "ИНН " + c.ReceivingDepositoryINN - } - rows = append(rows, journalRow{ - Time: c.CreatedAt, Dir: "Исходящий", DirKind: "out", - Party: party, SecN: len(c.Securities), - Status: c.Status, StatusText: russianState(c.Status), - Href: "/admin/claims/" + c.ID, - LiveKey: "out:" + c.ID, Live: liveOutgoing(c), - }) - } - } - // Входящие (переводы к нам). - for _, it := range store.List() { - party := m2mPartyName(it.SenderCode) - if party == "" { - party = m2mPartyName(it.SenderINN) - } - if party != "" { - party += " · " + it.SenderCode - } else { - party = it.SenderCode - } - cls, txt, outcome := incomingJournalStatus(it) - rows = append(rows, journalRow{ - Time: it.ReceivedAt, Dir: "Входящий", DirKind: "in", - Party: party, SecN: len(it.Securities), - Status: cls, StatusText: txt, Outcome: outcome, - Href: "/admin/incoming/" + it.ID, - LiveKey: "in:" + it.ID, Live: liveIncoming(it), - }) - } - sort.Slice(rows, func(i, j int) bool { return rows[i].Time.After(rows[j].Time) }) - + rows := buildJournalRows(r.Context(), svc, store) data := struct { page Rows []journalRow }{nowPage("Журнал обмена", "journal"), rows} render(w, a.journal, data) }) + + // /admin/journal/rows — только новые строки (появившиеся после since, unix-наносекунды). + // Страница подставляет их сверху без перезагрузки. count — общее число строк (для счётчика). + mux.HandleFunc("/admin/journal/rows", func(w http.ResponseWriter, r *http.Request) { + rows := buildJournalRows(r.Context(), svc, store) + var since int64 + if s := r.URL.Query().Get("since"); s != "" { + since, _ = strconv.ParseInt(s, 10, 64) + } + type rowJSON struct { + Key string `json:"key"` + T int64 `json:"t"` + HTML string `json:"html"` + } + out := make([]rowJSON, 0, 8) + for _, row := range rows { + t := row.Time.UnixNano() + if since > 0 && t <= since { + continue // уже есть на странице + } + var buf bytes.Buffer + if err := a.journal.ExecuteTemplate(&buf, "journalrow", row); err != nil { + continue + } + out = append(out, rowJSON{Key: row.LiveKey, T: t, HTML: buf.String()}) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]any{"count": len(rows), "rows": out}) + }) } diff --git a/internal/lkgateway/web/templates/admin_journal.html b/internal/lkgateway/web/templates/admin_journal.html index 82cc9ae..9c48d7f 100644 --- a/internal/lkgateway/web/templates/admin_journal.html +++ b/internal/lkgateway/web/templates/admin_journal.html @@ -1,25 +1,22 @@ {{define "content"}}
-

Журнал обмена ({{len .Rows}})

-

Все переводы в одной ленте — входящие и исходящие. Отдельные виды: Исходящие · Входящие.

- {{if .Rows}} +

Журнал обмена ({{len .Rows}})

+

Все переводы в одной ленте — входящие и исходящие. Отдельные виды: Исходящие · Входящие. новые заявки появляются автоматически

- - {{range .Rows}} - - - - - - - - - {{end}} + + {{range .Rows}}{{template "journalrow" .}}{{end}} +
ВремяНаправлениеКонтрагентЦБСтатус
{{msk .Time}}{{if eq .DirKind "out"}}↗ Исходящий{{else}}↘ Входящий{{end}}{{.Party}}{{.SecN}}{{.StatusText}}{{if .Outcome}} {{.Outcome}}{{end}}
{{.Live.Text}}
открыть →
Переводов пока нет.
- {{else}} -

Переводов пока нет.

- {{end}}
{{end}} + +{{define "journalrow"}} + {{msk .Time}} + {{if eq .DirKind "out"}}↗ Исходящий{{else}}↘ Входящий{{end}} + {{.Party}} + {{.SecN}} + {{.StatusText}}{{if .Outcome}} {{.Outcome}}{{end}}
{{.Live.Text}}
+ открыть → +{{end}} diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html index ceb97a4..2f0670a 100644 --- a/internal/lkgateway/web/templates/layout.html +++ b/internal/lkgateway/web/templates/layout.html @@ -175,6 +175,10 @@ tbody tr:hover td { background:var(--accent-weak); } .live-meta.changed { color:var(--warn,#8a5a00); } .live-meta.lost { color:var(--err,#b3261e); } @keyframes livedot { 50% { opacity:.3 } } +@keyframes bjRowIn { 0% { background:#fff3bf; } 100% { background:transparent; } } +#journal-body tr.row-new > td { animation: bjRowIn 3.8s ease-out; } +#journal-live-hint .dot { background:var(--ok,#2e7d32); animation:livedot 2s ease-in-out infinite; } +@media (prefers-reduced-motion: reduce){ #journal-body tr.row-new > td { animation:none; } #journal-live-hint .dot{ animation:none; } } @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); } @@ -836,6 +840,21 @@ button.dq-link, .dq-link { background:none !important; border:0 !important; padd 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){} + // Автообновление карточки перерисовывает страницу через перезагрузку. Сохраняем позицию + // прокрутки, чтобы при чтении заявку не отбрасывало наверх (автообновление остаётся). + var scrollKey = 'bj-scroll:' + location.pathname; + function bjReloadKeepScroll(){ try { sessionStorage.setItem(scrollKey, String(window.scrollY||window.pageYOffset||0)); } catch(e){} location.reload(); } + try { + var sy = sessionStorage.getItem(scrollKey); + if (sy !== null) { + sessionStorage.removeItem(scrollKey); + var syY = parseInt(sy, 10) || 0; + if ('scrollRestoration' in history) { try { history.scrollRestoration = 'manual'; } catch(e){} } + window.scrollTo(0, syY); + setTimeout(function(){ window.scrollTo(0, syY); }, 0); + window.addEventListener('load', function(){ window.scrollTo(0, syY); }); + } + } 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; @@ -857,7 +876,7 @@ button.dq-link, .dq-link { background:none !important; border:0 !important; padd 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; } + if (!dirty) { bjReloadKeepScroll(); return; } meta(e, 'Заявка изменилась. Обновить страницу (отмеченное в форме не сохранится)', 'changed'); e.setAttribute('data-sig', st.sig); } @@ -876,6 +895,51 @@ button.dq-link, .dq-link { background:none !important; border:0 !important; padd setInterval(tick, 8000); if (document.querySelector('.live[data-live][data-active]')) setTimeout(tick, 1500); })(); + // Журнал обмена: новые заявки подгружаются и всплывают сверху без перезагрузки страницы. + (function(){ + var body = document.getElementById('journal-body'); + if (!body) return; + var countEl = document.getElementById('journal-count'); + var empty = document.getElementById('journal-empty'); + function maxTime(){ + var mx = '0'; + body.querySelectorAll('tr[data-time]').forEach(function(tr){ + var t = tr.getAttribute('data-time') || '0'; + if (t.length > mx.length || (t.length === mx.length && t > mx)) mx = t; + }); + return mx; + } + function seenKeys(){ + var s = {}; + body.querySelectorAll('tr[data-key]').forEach(function(tr){ s[tr.getAttribute('data-key')] = true; }); + return s; + } + function tick(){ + fetch('/admin/journal/rows?since=' + encodeURIComponent(maxTime()), {cache:'no-store'}) + .then(function(r){ return r.json(); }) + .then(function(d){ + if (d && typeof d.count === 'number' && countEl) countEl.textContent = d.count; + if (!d || !d.rows || !d.rows.length) return; + var seen = seenKeys(); + d.rows.sort(function(a,b){ return a.t < b.t ? -1 : a.t > b.t ? 1 : 0; }); // старые первыми — новые всплывают выше + var added = 0; + d.rows.forEach(function(row){ + if (!row.key || seen[row.key]) return; + var tmp = document.createElement('tbody'); + tmp.innerHTML = (row.html || '').trim(); + var tr = tmp.firstElementChild; + if (!tr) return; + tr.classList.add('row-new'); + body.insertBefore(tr, body.firstChild); + seen[row.key] = true; added++; + setTimeout((function(el){ return function(){ el.classList.remove('row-new'); }; })(tr), 4000); + }); + if (added && empty) empty.hidden = true; + }) + .catch(function(){}); + } + setInterval(tick, 8000); + })(); })(); })();