Журнал обмена: автопоявление новых заявок; карточка не прыгает наверх при автообновлении

Журнал: новый эндпоинт /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 <noreply@anthropic.com>
This commit is contained in:
fontvielle
2026-10-13 12:50:20 +03:00
parent fc067a3437
commit 8f44d6fcb9
3 changed files with 160 additions and 60 deletions
+81 -42
View File
@@ -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})
})
}
@@ -1,25 +1,22 @@
{{define "content"}}
<div class="card">
<h2>Журнал обмена <span class="muted">({{len .Rows}})</span></h2>
<p class="muted">Все переводы в одной ленте — входящие и исходящие. Отдельные виды: <a href="/admin/claims">Исходящие</a> · <a href="/admin/incoming">Входящие</a>.</p>
{{if .Rows}}
<h2>Журнал обмена <span class="muted">(<span id="journal-count">{{len .Rows}}</span>)</span></h2>
<p class="muted">Все переводы в одной ленте — входящие и исходящие. Отдельные виды: <a href="/admin/claims">Исходящие</a> · <a href="/admin/incoming">Входящие</a>. <span class="muted" id="journal-live-hint"><span class="dot"></span>новые заявки появляются автоматически</span></p>
<table>
<thead><tr><th>Время</th><th>Направление</th><th>Контрагент</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .Rows}}
<tr>
<td style="white-space:nowrap">{{msk .Time}}</td>
<td>{{if eq .DirKind "out"}}<span class="badge" style="background:#e7f0fb;color:#1f4e79">↗ Исходящий</span>{{else}}<span class="badge" style="background:#e6f4ea;color:#1a7f37">↘ Входящий</span>{{end}}</td>
<td>{{.Party}}</td>
<td>{{.SecN}}</td>
<td><span class="badge {{.Status}}">{{.StatusText}}</span>{{if .Outcome}} <span class="badge {{if eq .Outcome "Принято"}}confirmed{{else if eq .Outcome "Отказано"}}rejected{{else}}awaiting_decision{{end}}">{{.Outcome}}</span>{{end}}<div class="live live-{{.Live.Kind}}" data-live="{{.LiveKey}}"{{if .Live.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{.Live.Text}}</div></td>
<td><a href="{{.Href}}" class="muted">открыть →</a></td>
</tr>
{{end}}
<tbody id="journal-body">
{{range .Rows}}{{template "journalrow" .}}{{end}}
<tr id="journal-empty"{{if .Rows}} hidden{{end}}><td colspan="6" class="muted">Переводов пока нет.</td></tr>
</tbody>
</table>
{{else}}
<p class="muted">Переводов пока нет.</p>
{{end}}
</div>
{{end}}
{{define "journalrow"}}<tr data-key="{{.LiveKey}}" data-time="{{.Time.UnixNano}}">
<td style="white-space:nowrap">{{msk .Time}}</td>
<td>{{if eq .DirKind "out"}}<span class="badge" style="background:#e7f0fb;color:#1f4e79">↗ Исходящий</span>{{else}}<span class="badge" style="background:#e6f4ea;color:#1a7f37">↘ Входящий</span>{{end}}</td>
<td>{{.Party}}</td>
<td>{{.SecN}}</td>
<td><span class="badge {{.Status}}">{{.StatusText}}</span>{{if .Outcome}} <span class="badge {{if eq .Outcome "Принято"}}confirmed{{else if eq .Outcome "Отказано"}}rejected{{else}}awaiting_decision{{end}}">{{.Outcome}}</span>{{end}}<div class="live live-{{.Live.Kind}}" data-live="{{.LiveKey}}"{{if .Live.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{.Live.Text}}</div></td>
<td><a href="{{.Href}}" class="muted">открыть →</a></td>
</tr>{{end}}
+65 -1
View File
@@ -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, 'Заявка изменилась. <a href="' + location.pathname + location.search + '">Обновить страницу</a> (отмеченное в форме не сохранится)', '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);
})();
})();
})();
</script>