Журнал обмена: автопоявление новых заявок; карточка не прыгает наверх при автообновлении
Журнал: новый эндпоинт /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 (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
"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}
|
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 — объединённая лента вход+исход.
|
// registerJournal вешает /admin/journal — объединённая лента вход+исход.
|
||||||
func registerJournal(mux *http.ServeMux, a *admin, svc *Service, store *IncomingStore) {
|
func registerJournal(mux *http.ServeMux, a *admin, svc *Service, store *IncomingStore) {
|
||||||
registerLiveStatus(mux, svc, store) // «чего ждёт заявка» для строк в работе
|
registerLiveStatus(mux, svc, store) // «чего ждёт заявка» для строк в работе
|
||||||
mux.HandleFunc("/admin/journal", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/admin/journal", func(w http.ResponseWriter, r *http.Request) {
|
||||||
var rows []journalRow
|
rows := buildJournalRows(r.Context(), svc, store)
|
||||||
|
|
||||||
// Исходящие (наши заявки в НРД).
|
|
||||||
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) })
|
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
page
|
page
|
||||||
Rows []journalRow
|
Rows []journalRow
|
||||||
}{nowPage("Журнал обмена", "journal"), rows}
|
}{nowPage("Журнал обмена", "journal"), rows}
|
||||||
render(w, a.journal, data)
|
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"}}
|
{{define "content"}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Журнал обмена <span class="muted">({{len .Rows}})</span></h2>
|
<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>.</p>
|
<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>
|
||||||
{{if .Rows}}
|
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Время</th><th>Направление</th><th>Контрагент</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
<thead><tr><th>Время</th><th>Направление</th><th>Контрагент</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody id="journal-body">
|
||||||
{{range .Rows}}
|
{{range .Rows}}{{template "journalrow" .}}{{end}}
|
||||||
<tr>
|
<tr id="journal-empty"{{if .Rows}} hidden{{end}}><td colspan="6" class="muted">Переводов пока нет.</td></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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{else}}
|
|
||||||
<p class="muted">Переводов пока нет.</p>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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.changed { color:var(--warn,#8a5a00); }
|
||||||
.live-meta.lost { color:var(--err,#b3261e); }
|
.live-meta.lost { color:var(--err,#b3261e); }
|
||||||
@keyframes livedot { 50% { opacity:.3 } }
|
@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 } }
|
@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); }
|
||||||
@@ -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('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);
|
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){}
|
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 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){
|
function meta(e, text, cls){
|
||||||
var m = e.nextElementSibling;
|
var m = e.nextElementSibling;
|
||||||
@@ -857,7 +876,7 @@ button.dq-link, .dq-link { background:none !important; border:0 !important; padd
|
|||||||
if (!st) return;
|
if (!st) return;
|
||||||
var big = e.classList.contains('live-big'), was = e.getAttribute('data-sig');
|
var big = e.classList.contains('live-big'), was = e.getAttribute('data-sig');
|
||||||
if (big && was && st.sig && st.sig !== was) {
|
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');
|
meta(e, 'Заявка изменилась. <a href="' + location.pathname + location.search + '">Обновить страницу</a> (отмеченное в форме не сохранится)', 'changed');
|
||||||
e.setAttribute('data-sig', st.sig);
|
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);
|
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);
|
||||||
})();
|
})();
|
||||||
|
// Журнал обмена: новые заявки подгружаются и всплывают сверху без перезагрузки страницы.
|
||||||
|
(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>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user