fix: 同步记录弹窗改为局部刷新 (#62)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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("准备同步中页面状态失败")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user