Журнал обмена: автопоявление новых заявок; карточка не прыгает наверх при автообновлении
Журнал: новый эндпоинт /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:
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user