fix: 同步记录弹窗改为局部刷新 (#62)

This commit is contained in:
chengma
2026-08-09 21:02:28 +08:00
parent ee1e060e3a
commit d8a24247df
9 changed files with 243 additions and 62 deletions
+51 -1
View File
@@ -156,14 +156,39 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
"SyncRunning": syncStatus.Running,
"SyncHistory": history,
"NeedSyncHistory": c.Query("history") == "1",
"HistoryRefreshURL": sybHistoryURL(history.Page,
"HistoryFetchURL": sybHistoryPartialURL(history.Page,
keyword, dateFrom, dateTo),
"HistoryPagination": sybHistoryPagination(history.Page, history.TotalPages,
keyword, dateFrom, dateTo),
"HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages,
keyword, dateFrom, dateTo),
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
}))
}
// SybSyncHistory 只返回同步记录弹窗内部的 HTML。它只读本地数据库,不调用
// 顺运宝;完整页面和局部刷新共用同一份模板,避免字段或分页标记漂移。
func (h *Handler) SybSyncHistory(c *gin.Context) {
c.Header("Cache-Control", "no-store")
keyword := c.Query("order_no")
dateFrom := c.Query("date_from")
dateTo := c.Query("date_to")
history, err := service.ListSybSyncHistory(h.db, service.ParsePage(c.Query("history_page")))
if err != nil {
c.String(http.StatusInternalServerError, "读取同步记录失败,请稍后重试")
return
}
c.HTML(http.StatusOK, "syb/history_partial", gin.H{
"SyncHistory": history,
"HistoryFetchURL": sybHistoryPartialURL(history.Page,
keyword, dateFrom, dateTo),
"HistoryPagination": sybHistoryPagination(history.Page, history.TotalPages,
keyword, dateFrom, dateTo),
"HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages,
keyword, dateFrom, dateTo),
})
}
func sybHistoryPagination(pageNum, totalPages int, keyword, dateFrom, dateTo string) service.PaginationView {
link := func(target int) string { return sybHistoryURL(target, keyword, dateFrom, dateTo) }
view := service.PaginationView{
@@ -189,6 +214,31 @@ func sybHistoryURL(pageNum int, keyword, dateFrom, dateTo string) string {
return "/syb?" + values.Encode()
}
func sybHistoryPartialPagination(pageNum, totalPages int, keyword, dateFrom, dateTo string) service.PaginationView {
link := func(target int) string { return sybHistoryPartialURL(target, keyword, dateFrom, dateTo) }
view := service.PaginationView{
Page: pageNum, TotalPages: totalPages, HasPrev: pageNum > 1, HasNext: pageNum < totalPages,
FirstURL: link(1), LastURL: link(totalPages),
}
if view.HasPrev {
view.PrevURL = link(pageNum - 1)
}
if view.HasNext {
view.NextURL = link(pageNum + 1)
}
return view
}
func sybHistoryPartialURL(pageNum int, keyword, dateFrom, dateTo string) string {
values := url.Values{"history_page": {strconv.Itoa(pageNum)}}
if keyword != "" {
values.Set("order_no", keyword)
}
values.Set("date_from", dateFrom)
values.Set("date_to", dateTo)
return "/syb/sync-history?" + values.Encode()
}
// sybStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。
// `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。
func sybStatusLine(result *service.SybListResult) string {
+32
View File
@@ -115,3 +115,35 @@ func TestSybHistoryURL_刷新时保留当前页和筛选(t *testing.T) {
}
}
}
func TestSybHistoryPartialURL_只读片段保留当前页和筛选(t *testing.T) {
location, err := url.Parse(sybHistoryPartialURL(3, "ORDER-1", "2026-08-01", "2026-08-09"))
if err != nil {
t.Fatal(err)
}
if location.Path != "/syb/sync-history" {
t.Fatalf("局部刷新应使用独立只读路由,实际 %s", location.Path)
}
query := location.Query()
for name, want := range map[string]string{
"history_page": "3", "order_no": "ORDER-1",
"date_from": "2026-08-01", "date_to": "2026-08-09",
} {
if got := query.Get(name); got != want {
t.Fatalf("局部刷新链接 %s=%q,期望 %q;URL=%s", name, got, want, location.String())
}
}
}
func TestSybHistoryPartialPagination_翻页继续使用片段路由(t *testing.T) {
view := sybHistoryPartialPagination(2, 3, "ORDER-1", "2026-08-01", "2026-08-09")
for _, raw := range []string{view.FirstURL, view.PrevURL, view.NextURL, view.LastURL} {
location, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if location.Path != "/syb/sync-history" {
t.Fatalf("弹窗局部翻页不应请求完整 SYB 页面:%s", raw)
}
}
}
+2 -1
View File
@@ -71,7 +71,8 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
// 3. 顺运宝数据
pages.GET("/syb", h.SybList)
pages.GET("/syb/captcha", h.SybCaptcha) // 登录弹窗里的验证码图片
pages.GET("/syb/sync-history", h.SybSyncHistory) // 弹窗局部刷新,只读本地同步记录
pages.GET("/syb/captcha", h.SybCaptcha) // 登录弹窗里的验证码图片
pages.POST("/syb/sync", h.SybSync)
pages.POST("/syb/login-and-sync", h.SybLoginAndSync)
pages.POST("/syb/match", h.SybMatch)
+29 -1
View File
@@ -67,7 +67,8 @@ func TestMainPagesReturnOK(t *testing.T) {
sybResponse := httptest.NewRecorder()
router.ServeHTTP(sybResponse, sybRequest)
for _, want := range []string{
"同步记录", "刷新记录", `data-history-refresh-url=`,
"同步记录", "刷新同步记录", `data-syb-history-slot`,
`data-history-fetch-url=`,
`name="date_from"`, `name="date_to"`,
"按顺运宝货运单创建日期(UTC+8)同步",
} {
@@ -79,6 +80,33 @@ func TestMainPagesReturnOK(t *testing.T) {
t.Error("顺运宝页面不应再显示旧的指定日期同步入口")
}
partialRequest := httptest.NewRequest(http.MethodGet,
"/syb/sync-history?history_page=1&order_no=ORDER-1&date_from=2026-08-01&date_to=2026-08-09", nil)
addAuth(partialRequest)
partialResponse := httptest.NewRecorder()
router.ServeHTTP(partialResponse, partialRequest)
partialBody := partialResponse.Body.String()
if partialResponse.Code != http.StatusOK ||
!strings.Contains(partialBody, "刷新同步记录") ||
!strings.Contains(partialBody, `data-history-fetch-url=`) {
t.Fatalf("同步记录片段响应不完整:%d %s", partialResponse.Code, partialBody)
}
if partialResponse.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("同步记录刷新响应不得被浏览器缓存,实际 Cache-Control=%q",
partialResponse.Header().Get("Cache-Control"))
}
if strings.Contains(partialBody, `data-syb-sync-form`) || strings.Contains(partialBody, `modal-backdrop`) {
t.Fatalf("同步记录片段不应包含 SYB 主页面或弹窗外壳:%s", partialBody)
}
unauthorizedPartial := httptest.NewRequest(http.MethodGet, "/syb/sync-history", nil)
unauthorizedPartialResponse := httptest.NewRecorder()
router.ServeHTTP(unauthorizedPartialResponse, unauthorizedPartial)
if unauthorizedPartialResponse.Code != http.StatusSeeOther ||
!strings.HasPrefix(unauthorizedPartialResponse.Header().Get("Location"), "/login") {
t.Fatalf("匿名读取同步记录片段应跳转登录,实际 %d %s",
unauthorizedPartialResponse.Code, unauthorizedPartialResponse.Header().Get("Location"))
}
if !service.TryStartSybSync() {
t.Fatal("准备同步中页面状态失败")
}
+3
View File
@@ -290,6 +290,9 @@ tr.empty small { color: #aaa; }
}
.sync-history-page { color: #555; white-space: nowrap; }
.sync-history-foot { flex-wrap: wrap; }
.sync-history-loading { margin: 0; white-space: nowrap; }
.sync-history-load-error { margin-top: 0; }
.sync-history-foot [aria-disabled="true"] { opacity: .5; pointer-events: none; }
.sync-status {
display: inline-block;
min-width: 52px;
+63 -10
View File
@@ -265,17 +265,70 @@
});
}
/* 同步记录继续由服务端渲染。刷新按钮带着 history=1 和当前页码重新加载,
返回后弹窗会自动打开;点击后先显示加载态,避免连续刷新。 */
/* 同步记录继续由服务端渲染,但只替换弹窗内部的 HTML 片段。
事件委托挂在不会被替换的 slot 上,所以刷新和翻页后不用重新绑定。 */
function setupSybHistoryRefresh() {
var button = document.querySelector("[data-history-refresh-url]");
if (!button) return;
button.addEventListener("click", function () {
if (button.disabled) return;
button.disabled = true;
button.setAttribute("aria-busy", "true");
button.textContent = "刷新中…";
window.location.assign(button.getAttribute("data-history-refresh-url"));
var slot = document.querySelector("[data-syb-history-slot]");
if (!slot) return;
slot.addEventListener("click", function (event) {
var trigger = event.target.closest("[data-history-fetch-url]");
if (!trigger || !slot.contains(trigger)) return;
event.preventDefault();
if (slot.getAttribute("aria-busy") === "true") return;
var fetchURL = trigger.getAttribute("data-history-fetch-url");
var refreshButton = slot.querySelector("button[data-history-fetch-url]");
var loading = slot.querySelector("[data-history-loading]");
var previousError = slot.querySelector("[data-history-load-error]");
var isRefreshButton = trigger === refreshButton;
slot.setAttribute("aria-busy", "true");
slot.querySelectorAll("[data-history-fetch-url]").forEach(function (control) {
control.setAttribute("aria-disabled", "true");
});
if (refreshButton) refreshButton.disabled = true;
if (loading) loading.hidden = false;
if (previousError) previousError.hidden = true;
if (isRefreshButton) refreshButton.textContent = "刷新中…";
fetch(fetchURL, {
cache: "no-store",
headers: { "X-Requested-With": "fetch" }
})
.then(function (response) {
if (response.redirected) {
window.location.assign(response.url);
return null;
}
if (!response.ok) throw new Error("HTTP " + response.status);
return response.text();
})
.then(function (html) {
if (html === null) return;
slot.innerHTML = html;
slot.removeAttribute("aria-busy");
var nextRefreshButton = slot.querySelector("button[data-history-fetch-url]");
if (nextRefreshButton) nextRefreshButton.focus({ preventScroll: true });
})
.catch(function (error) {
slot.removeAttribute("aria-busy");
slot.querySelectorAll("[data-history-fetch-url]").forEach(function (control) {
control.removeAttribute("aria-disabled");
});
refreshButton = slot.querySelector("button[data-history-fetch-url]");
loading = slot.querySelector("[data-history-loading]");
var errorBox = slot.querySelector("[data-history-load-error]");
if (refreshButton) {
refreshButton.disabled = false;
refreshButton.textContent = "刷新同步记录";
refreshButton.focus({ preventScroll: true });
}
if (loading) loading.hidden = true;
if (errorBox) {
errorBox.textContent = "刷新同步记录失败:" + error.message + "。请重试。";
errorBox.hidden = false;
}
});
});
}
+55
View File
@@ -0,0 +1,55 @@
{{define "syb/history_partial"}}
<div class="modal-body">
<p class="missing sync-history-load-error" role="alert" data-history-load-error hidden></p>
<p class="hint">共 {{.SyncHistory.Total}} 条,按发起时间从新到旧排列。</p>
<div class="table-wrap sync-history-table">
<table>
<thead>
<tr>
<th>状态</th><th>日期范围</th><th>操作账号</th><th>开始时间</th>
<th>完成时间</th><th>同步结果</th><th>覆盖进度</th><th>失败原因</th>
</tr>
</thead>
<tbody>
{{range .SyncHistory.Rows}}
<tr>
<td><span class="sync-status {{.StatusClass}}">{{.StatusText}}</span></td>
<td>{{.DateRange}}</td>
<td>{{.Username}}</td>
<td>{{.StartedAt}}</td>
<td>{{.FinishedAt}}</td>
<td>{{.Summary}}</td>
<td>{{.CursorText}}</td>
<td class="sync-error">{{if .ErrorMessage}}{{.ErrorMessage}}{{else}}—{{end}}</td>
</tr>
{{else}}
<tr class="empty"><td colspan="8">还没有同步记录。</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="modal-foot sync-history-foot">
<span class="grow"></span>
<span class="hint sync-history-loading" role="status" data-history-loading hidden>正在加载同步记录…</span>
<button type="button" data-history-fetch-url="{{.HistoryFetchURL}}">刷新同步记录</button>
<span class="sync-history-page">第 {{.SyncHistory.Page}} / {{.SyncHistory.TotalPages}} 页</span>
{{if .HistoryPagination.HasPrev}}
<a class="button-link" href="{{.HistoryPagination.FirstURL}}"
data-history-fetch-url="{{.HistoryFetchPagination.FirstURL}}">首页</a>
<a class="button-link" href="{{.HistoryPagination.PrevURL}}"
data-history-fetch-url="{{.HistoryFetchPagination.PrevURL}}">上一页</a>
{{else}}
<span class="button-link disabled">首页</span><span class="button-link disabled">上一页</span>
{{end}}
{{if .HistoryPagination.HasNext}}
<a class="button-link" href="{{.HistoryPagination.NextURL}}"
data-history-fetch-url="{{.HistoryFetchPagination.NextURL}}">下一页</a>
<a class="button-link" href="{{.HistoryPagination.LastURL}}"
data-history-fetch-url="{{.HistoryFetchPagination.LastURL}}">末页</a>
{{else}}
<span class="button-link disabled">下一页</span><span class="button-link disabled">末页</span>
{{end}}
<button type="button" data-modal-close>关闭</button>
</div>
{{end}}
+2 -46
View File
@@ -52,52 +52,8 @@
<h2 id="sync-history-modal-title">同步记录</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<div class="modal-body">
<p class="hint">共 {{.SyncHistory.Total}} 条,按发起时间从新到旧排列。</p>
<div class="table-wrap sync-history-table">
<table>
<thead>
<tr>
<th>状态</th><th>日期范围</th><th>操作账号</th><th>开始时间</th>
<th>完成时间</th><th>同步结果</th><th>覆盖进度</th><th>失败原因</th>
</tr>
</thead>
<tbody>
{{range .SyncHistory.Rows}}
<tr>
<td><span class="sync-status {{.StatusClass}}">{{.StatusText}}</span></td>
<td>{{.DateRange}}</td>
<td>{{.Username}}</td>
<td>{{.StartedAt}}</td>
<td>{{.FinishedAt}}</td>
<td>{{.Summary}}</td>
<td>{{.CursorText}}</td>
<td class="sync-error">{{if .ErrorMessage}}{{.ErrorMessage}}{{else}}—{{end}}</td>
</tr>
{{else}}
<tr class="empty"><td colspan="8">还没有同步记录。</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="modal-foot sync-history-foot">
<span class="grow"></span>
<button type="button" data-history-refresh-url="{{.HistoryRefreshURL}}">刷新记录</button>
<span class="sync-history-page">第 {{.SyncHistory.Page}} / {{.SyncHistory.TotalPages}} 页</span>
{{if .HistoryPagination.HasPrev}}
<a class="button-link" href="{{.HistoryPagination.FirstURL}}">首页</a>
<a class="button-link" href="{{.HistoryPagination.PrevURL}}">上一页</a>
{{else}}
<span class="button-link disabled">首页</span><span class="button-link disabled">上一页</span>
{{end}}
{{if .HistoryPagination.HasNext}}
<a class="button-link" href="{{.HistoryPagination.NextURL}}">下一页</a>
<a class="button-link" href="{{.HistoryPagination.LastURL}}">末页</a>
{{else}}
<span class="button-link disabled">下一页</span><span class="button-link disabled">末页</span>
{{end}}
<button type="button" data-modal-close>关闭</button>
<div data-syb-history-slot>
{{template "syb/history_partial" .}}
</div>
</div>
</div>