diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index b0d0795..89ebe4f 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -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 { diff --git a/admin/handler/web/others_test.go b/admin/handler/web/others_test.go index 69832ff..0aa41d0 100644 --- a/admin/handler/web/others_test.go +++ b/admin/handler/web/others_test.go @@ -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) + } + } +} diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 9850c85..594b5a0 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -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) diff --git a/admin/main_test.go b/admin/main_test.go index 49303a5..f1c34b8 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -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("准备同步中页面状态失败") } diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 3052d85..f953705 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -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; diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 82b9831..9123cf3 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -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; + } + }); }); } diff --git a/admin/templates/syb/history_partial.html b/admin/templates/syb/history_partial.html new file mode 100644 index 0000000..e9070d1 --- /dev/null +++ b/admin/templates/syb/history_partial.html @@ -0,0 +1,55 @@ +{{define "syb/history_partial"}} + + +{{end}} diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index 74efa85..2b66946 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -52,52 +52,8 @@

同步记录

- - diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 9949851..39af74d 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -455,9 +455,12 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新 操作账号、开始/完成时间、同步统计、覆盖游标结果和失败原因。状态至少区分同步中、 成功、失败、已中断;不能只靠颜色表达。刷新或重启 Admin 后记录仍可查看。 -弹窗底部右侧依次放“刷新记录”、当前页、翻页组件和“关闭”。点击刷新后按钮显示 -“刷新中…”并暂时禁用;通过普通 GET 保留当前历史页码、订单号和日期范围,页面 -返回后自动重开弹窗。本阶段不自动轮询,避免阅读历史记录时内容突然移动。 +弹窗底部右侧依次放“刷新同步记录”、当前页、翻页组件和“关闭”。点击刷新后按钮 +显示“刷新中…”并暂时禁用;刷新和弹窗内翻页只请求受网页登录保护的同步记录 +HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表的滚动、勾选和筛选 +状态不改变,弹窗也不关闭;失败时保留原记录并在弹窗内给出重试提示。无 JavaScript +时翻页链接降级为重载 `/syb` 并自动重开弹窗。本阶段不自动轮询,避免阅读历史 +记录时内容突然移动;F5 仍按浏览器默认行为刷新整个 SYB 页面。 `[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。