diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 8e32c95..5c11714 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -86,7 +86,17 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, // 现在「打开页面」纯粹是看数据,不触发任何对外部系统的动作; // 真正会登录的只有「同步」这一个按钮。这与 Client 侧 // 「『获取任务』是唯一会产生外部后果的命令」是同一条原则。 - needLogin := loginReason != "" + needLogin := c.Query("login") == "1" + today := service.SybToday(time.Now()) + dateFrom := c.Query("date_from") + dateTo := c.Query("date_to") + hasSyncRange := dateFrom != "" || dateTo != "" + if dateFrom == "" { + dateFrom = today + } + if dateTo == "" { + dateTo = today + } cfg, cfgErr := config.Load() switch { @@ -119,6 +129,12 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, "LoginHint": loginHint, "Username": username, "ConfigProblem": configProblem, + "Today": today, + "SyncDateFrom": dateFrom, + "SyncDateTo": dateTo, + "HasSyncRange": hasSyncRange, + "NeedRangeSync": c.Query("range_sync") == "1", + "RangeError": c.Query("range_error"), "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), })) } @@ -142,6 +158,12 @@ func sybStatusLine(result *service.SybListResult) string { // │ └→ 识别失败/OCR 不可达 ──┐ // └→ OCR 未配置(留空)────────────────────┴→ 跳回列表页,弹手工登录框 func (h *Handler) SybSync(c *gin.Context) { + options, err := service.NewSybSyncOptions(c.PostForm("date_from"), c.PostForm("date_to"), time.Now()) + if err != nil { + h.sybRedirectRange(c, err.Error()) + return + } + cfg, err := config.Load() if err != nil { fail(c, http.StatusBadRequest, err.Error()) @@ -166,7 +188,7 @@ func (h *Handler) SybSync(c *gin.Context) { h.sybRedirectLogin(c, reason) return } - if !h.startSybSync(client, *cfg) { + if !h.startSybSync(client, *cfg, options) { h.sybRedirect(c, "验证码自动识别成功,已自动登录;但已经有一个同步任务在跑,请稍后刷新页面查看结果") return } @@ -174,7 +196,7 @@ func (h *Handler) SybSync(c *gin.Context) { return } - if !h.startSybSync(client, *cfg) { + if !h.startSybSync(client, *cfg, options) { h.sybRedirect(c, "已经有一个同步任务在跑,请稍后刷新页面查看结果,不要重复点击") return } @@ -244,6 +266,12 @@ func (h *Handler) SybCaptcha(c *gin.Context) { // `[必须]` 密码从 config.yaml 读,不接受表单传入、不回显、不进日志—— // 界面上只有验证码是操作员手输的,见工单 #46。 func (h *Handler) SybLoginAndSync(c *gin.Context) { + options, optionErr := service.NewSybSyncOptions(c.PostForm("date_from"), c.PostForm("date_to"), time.Now()) + if optionErr != nil { + h.sybRedirectRange(c, optionErr.Error()) + return + } + cfg, err := config.Load() if err != nil { fail(c, http.StatusBadRequest, err.Error()) @@ -275,7 +303,7 @@ func (h *Handler) SybLoginAndSync(c *gin.Context) { log.Printf("syb_session_save_failed username=%s err=%v", result.User.Username, err) } - if !h.startSybSync(client, *cfg) { + if !h.startSybSync(client, *cfg, options) { h.sybRedirect(c, "登录成功,但已经有一个同步任务在跑,请稍后刷新页面查看结果") return } @@ -290,13 +318,13 @@ func (h *Handler) SybLoginAndSync(c *gin.Context) { // // `[必须]` 后台协程用 context.Background(),不能用 c.Request.Context()—— // 那个请求上下文会在这次 HTTP 请求返回后就被取消,同步跑到一半会被打断。 -func (h *Handler) startSybSync(client *syb.Client, cfg config.Config) bool { +func (h *Handler) startSybSync(client *syb.Client, cfg config.Config, options service.SybSyncOptions) bool { if !service.TryStartSybSync() { return false } db := h.db go func() { - report := service.RunSybSync(context.Background(), db, client, cfg.Syb, time.Now()) + report := service.RunSybSyncWithOptions(context.Background(), db, client, cfg.Syb, time.Now(), options) service.FinishSybSync(report) }() return true @@ -306,9 +334,7 @@ func (h *Handler) startSybSync(client *syb.Client, cfg config.Config) bool { // 用 303 跳转是为了让浏览器地址栏变成 GET /syb,按 F5 不会重复提交。 func (h *Handler) sybRedirect(c *gin.Context, msg string) { params := url.Values{} - if q := c.PostForm("order_no"); q != "" { - params.Set("order_no", q) - } + appendSybFormState(params, c) if msg != "" { params.Set("msg", msg) } @@ -325,9 +351,8 @@ func (h *Handler) sybRedirect(c *gin.Context, msg string) { // (比如 ocr_url 留空,压根没尝试过自动识别)弹窗不显示任何原因说明。 func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) { params := url.Values{} - if q := c.PostForm("order_no"); q != "" { - params.Set("order_no", q) - } + appendSybFormState(params, c) + params.Set("login", "1") if reason != "" { params.Set("login_reason", reason) } @@ -338,6 +363,25 @@ func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) { c.Redirect(http.StatusSeeOther, target) } +// sybRedirectRange 把服务端日期校验错误带回指定日期弹窗。输入值一并保留, +// 操作员只需改错的那一端,不必重新填整张表单。 +func (h *Handler) sybRedirectRange(c *gin.Context, message string) { + params := url.Values{} + appendSybFormState(params, c) + params.Set("range_sync", "1") + params.Set("range_error", message) + c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode()) +} + +// appendSybFormState 保存同步/登录跳转后仍需要的页面状态。 +func appendSybFormState(params url.Values, c *gin.Context) { + for _, name := range []string{"order_no", "date_from", "date_to"} { + if value := strings.TrimSpace(c.PostForm(name)); value != "" { + params.Set(name, value) + } + } +} + // SybMatch 保存规格匹配结果。 // // 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings), diff --git a/admin/handler/web/others_test.go b/admin/handler/web/others_test.go new file mode 100644 index 0000000..f14f133 --- /dev/null +++ b/admin/handler/web/others_test.go @@ -0,0 +1,68 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func sybPostContext(t *testing.T, values url.Values) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + request := httptest.NewRequest(http.MethodPost, "/syb/sync", strings.NewReader(values.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + context.Request = request + return context, recorder +} + +func TestSybRedirectLogin_保留指定日期并打开登录弹窗(t *testing.T) { + context, recorder := sybPostContext(t, url.Values{ + "order_no": {"ORDER-1"}, + "date_from": {"2026-08-01"}, + "date_to": {"2026-08-09"}, + }) + + (&Handler{}).sybRedirectLogin(context, "") + if context.Writer.Status() != http.StatusSeeOther { + t.Fatalf("状态码 = %d,期望 %d", context.Writer.Status(), http.StatusSeeOther) + } + location, err := url.Parse(recorder.Header().Get("Location")) + if err != nil { + t.Fatalf("解析 Location 失败: %v", err) + } + query := location.Query() + for name, want := range map[string]string{ + "login": "1", "order_no": "ORDER-1", + "date_from": "2026-08-01", "date_to": "2026-08-09", + } { + if got := query.Get(name); got != want { + t.Errorf("%s = %q,期望 %q;Location=%s", name, got, want, location.String()) + } + } +} + +func TestSybRedirectRange_保留输入并重新打开日期弹窗(t *testing.T) { + context, recorder := sybPostContext(t, url.Values{ + "date_from": {"2026-08-09"}, + "date_to": {"2026-08-01"}, + }) + + (&Handler{}).sybRedirectRange(context, "开始日期不能晚于结束日期") + location, err := url.Parse(recorder.Header().Get("Location")) + if err != nil { + t.Fatalf("解析 Location 失败: %v", err) + } + query := location.Query() + if query.Get("range_sync") != "1" || query.Get("range_error") == "" { + t.Fatalf("日期错误后应该重开弹窗并显示错误,Location=%s", location.String()) + } + if query.Get("date_from") != "2026-08-09" || query.Get("date_to") != "2026-08-01" { + t.Fatalf("日期错误后应该保留两端输入,Location=%s", location.String()) + } +} diff --git a/admin/main_test.go b/admin/main_test.go index a91ac3b..39d6d64 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -38,6 +38,18 @@ func TestMainPagesReturnOK(t *testing.T) { }) } + sybRequest := httptest.NewRequest(http.MethodGet, "/syb", nil) + sybResponse := httptest.NewRecorder() + router.ServeHTTP(sybResponse, sybRequest) + for _, want := range []string{ + "指定日期同步", `name="date_from"`, `name="date_to"`, + "按顺运宝货运单创建日期(UTC+8)同步", + } { + if !strings.Contains(sybResponse.Body.String(), want) { + t.Errorf("顺运宝页面缺少 %q", want) + } + } + pdd, err := repository.EnsurePddProduct( db, "737116531267", "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267") diff --git a/admin/service/syb.go b/admin/service/syb.go index 0f9b3ab..1e1e17a 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -32,6 +32,49 @@ import ( const dateLayout = "2006-01-02" +// SybSyncOptions 表示一次同步的日期选择。From/To 都为空是日常自动增量; +// 两者都有值是操作员明确发起的指定日期补同步。 +// +// `[必须]` 不允许只填一端。把“半个范围”悄悄退化成自动增量,会让操作员 +// 以为补拉了指定日期,实际却跑了另一段数据。 +type SybSyncOptions struct { + From string + To string +} + +// IsSpecified 判断这是不是指定日期同步。 +func (o SybSyncOptions) IsSpecified() bool { + return strings.TrimSpace(o.From) != "" || strings.TrimSpace(o.To) != "" +} + +// NewSybSyncOptions 清理并校验浏览器提交的日期范围。 +// 日期按顺运宝服务端 UTC+8 解释,起止两天都包含。 +func NewSybSyncOptions(fromRaw, toRaw string, now time.Time) (SybSyncOptions, error) { + from := strings.TrimSpace(fromRaw) + to := strings.TrimSpace(toRaw) + if from == "" && to == "" { + return SybSyncOptions{}, nil + } + if from == "" || to == "" { + return SybSyncOptions{}, fmt.Errorf("开始日期和结束日期必须同时填写") + } + fromDate, err := time.Parse(dateLayout, from) + if err != nil || fromDate.Format(dateLayout) != from { + return SybSyncOptions{}, fmt.Errorf("开始日期格式不正确,请使用 YYYY-MM-DD") + } + toDate, err := time.Parse(dateLayout, to) + if err != nil || toDate.Format(dateLayout) != to { + return SybSyncOptions{}, fmt.Errorf("结束日期格式不正确,请使用 YYYY-MM-DD") + } + if fromDate.After(toDate) { + return SybSyncOptions{}, fmt.Errorf("开始日期不能晚于结束日期") + } + if to > dateOf(now) { + return SybSyncOptions{}, fmt.Errorf("结束日期不能晚于顺运宝服务端今天(%s)", dateOf(now)) + } + return SybSyncOptions{From: from, To: to}, nil +} + // sybLocation 是顺运宝服务端的时区。 // // `[必须]` 日期范围筛的是服务端的 created,而它是 UTC+8——实测 @@ -57,6 +100,12 @@ func dateOf(t time.Time) string { return t.In(sybLocation).Format(dateLayout) } +// SybToday 返回顺运宝服务端时区下的今天,供页面 date 输入的 max/default +// 使用。页面和同步校验共用这一处,避免本机时区不同导致显示和校验差一天。 +func SybToday(now time.Time) string { + return dateOf(now) +} + // syncDateRange 算出这次同步该拉哪个日期范围。 // // `[必须]` 增量必须从"上次同步日期当天"重新拉,不是第二天——created @@ -97,8 +146,9 @@ type SkipNote struct { // SyncReport 是一次同步的结果,供状态条显示。 type SyncReport struct { From, To string - StockCount int // 拉到的货运单数 - DetailCount int // 落库的商品明细行数(不含跳过的) + Specified bool // true 表示操作员发起的指定日期补同步 + StockCount int // 拉到的货运单数 + DetailCount int // 落库的商品明细行数(不含跳过的) Created int Updated int SkippedZero int // quantity <= 0 被跳过的条数 @@ -116,7 +166,11 @@ func (r SyncReport) Summary() string { if r.Err != nil { return "同步失败:" + r.Err.Error() } - msg := fmt.Sprintf("同步完成:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)", + prefix := "同步完成" + if r.Specified { + prefix = "指定日期同步完成" + } + msg := fmt.Sprintf("%s:日期范围 %s ~ %s,货运单 %d 张,商品明细 %d 条(新增 %d,更新 %d,跳过 %d)", prefix, r.From, r.To, r.StockCount, r.DetailCount, r.Created, r.Updated, r.SkippedZero) if len(r.Notes) > 0 { var reasons []string @@ -222,7 +276,15 @@ func roundYuanToCent(yuan float64) int64 { // 如果会话恰好失效,会从 syb.Client 的调用里冒出 syb.ErrSessionInvalid, // 和其它错误一样按"中途失败"处理:不更新 last_synced_at,把原因写进报告。 func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time) SyncReport { - report := SyncReport{StartedAt: now} + return RunSybSyncWithOptions(ctx, db, client, cfg, now, SybSyncOptions{}) +} + +// RunSybSyncWithOptions 执行自动增量或指定日期同步。 +// +// 指定日期只有完整覆盖“本来应该自动同步的范围”时才推进 last_synced_at。 +// 局部历史补拉只 upsert 数据、不动游标,否则会让未覆盖的订单永久漏掉。 +func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time, options SybSyncOptions) SyncReport { + report := SyncReport{StartedAt: now, Specified: options.IsSpecified()} pageSize := cfg.PageSize if pageSize <= 0 { @@ -240,11 +302,28 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config. return report } - from, to, err := syncDateRange(lastSyncedAt, cfg.SyncFrom, now) - if err != nil { - report.Err = err - report.FinishedAt = time.Now().UTC() - return report + from, to := options.From, options.To + advanceCursor := !options.IsSpecified() + if options.IsSpecified() { + validated, validateErr := NewSybSyncOptions(options.From, options.To, now) + if validateErr != nil { + report.Err = validateErr + report.FinishedAt = time.Now().UTC() + return report + } + from, to = validated.From, validated.To + + // 算得出自动范围时,只有完整覆盖它才允许推进游标。首次同步没有 + // sync_from 时仍允许补拉,但不能凭局部范围建立一个不可靠的游标。 + autoFrom, autoTo, rangeErr := syncDateRange(lastSyncedAt, cfg.SyncFrom, now) + advanceCursor = rangeErr == nil && from <= autoFrom && to >= autoTo + } else { + from, to, err = syncDateRange(lastSyncedAt, cfg.SyncFrom, now) + if err != nil { + report.Err = err + report.FinishedAt = time.Now().UTC() + return report + } } report.From, report.To = from, to @@ -263,8 +342,10 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config. } if total == 0 { report.FinishedAt = time.Now().UTC() - if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil { - report.Err = fmt.Errorf("更新同步进度失败: %w", err) + if advanceCursor { + if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil { + report.Err = fmt.Errorf("更新同步进度失败: %w", err) + } } return report } @@ -329,9 +410,11 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config. } // ④ 全部成功,才更新 last_synced_at。 - if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil { - report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+ - "下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err) + if advanceCursor { + if err := repository.SetSybLastSyncedAt(db, model.NowISO()); err != nil { + report.Err = fmt.Errorf("同步数据已全部写入,但更新同步进度失败,"+ + "下次同步会重新拉这个日期范围(不会漏,但会重复拉一次): %w", err) + } } report.FinishedAt = time.Now().UTC() return report diff --git a/admin/service/syb_test.go b/admin/service/syb_test.go index 97a4ad0..e32eae0 100644 --- a/admin/service/syb_test.go +++ b/admin/service/syb_test.go @@ -84,6 +84,39 @@ func TestSyncDateRange_结束日期按顺运宝服务端时区UTC加8算不是UT } } +func TestNewSybSyncOptions_指定日期校验(t *testing.T) { + now := time.Date(2026, 8, 9, 23, 0, 0, 0, time.UTC) // UTC+8 已是 8 月 10 日 + cases := []struct { + name, from, to string + wantErr string + }{ + {name: "两端都空是自动增量"}, + {name: "合法闭区间", from: "2026-08-01", to: "2026-08-10"}, + {name: "只填开始", from: "2026-08-01", wantErr: "同时填写"}, + {name: "开始格式错误", from: "2026/08/01", to: "2026-08-02", wantErr: "开始日期格式"}, + {name: "结束格式错误", from: "2026-08-01", to: "2026/08/02", wantErr: "结束日期格式"}, + {name: "起止倒置", from: "2026-08-03", to: "2026-08-02", wantErr: "不能晚于"}, + {name: "晚于UTC加8今天", from: "2026-08-10", to: "2026-08-11", wantErr: "不能晚于"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := NewSybSyncOptions(tc.from, tc.to, now) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("不应该报错: %v", err) + } + if got.From != tc.from || got.To != tc.to { + t.Fatalf("范围被意外改变:%+v", got) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("错误应包含 %q,实际 %v", tc.wantErr, err) + } + }) + } +} + // ── 金额换算 ────────────────────────────────────────── func TestRoundYuanToCent_先四舍五入再转整数(t *testing.T) { @@ -367,6 +400,61 @@ func TestRunSybSync_全部成功后更新last_synced_at(t *testing.T) { } } +func TestRunSybSyncWithOptions_历史补拉不推进游标(t *testing.T) { + srv := fakeSybServer(t, nil, 0) + defer srv.Close() + db := newSyncTestDB(t) + client, _ := syb.New(srv.URL) + const original = "2026-07-20T06:30:00Z" + if err := repository.SetSybLastSyncedAt(db, original); err != nil { + t.Fatalf("准备同步游标失败: %v", err) + } + + report := RunSybSyncWithOptions(context.Background(), db, client, + config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}, + time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC), + SybSyncOptions{From: "2026-07-01", To: "2026-07-10"}) + if report.Err != nil { + t.Fatalf("历史补拉失败: %v", report.Err) + } + if !report.Specified || !strings.HasPrefix(report.Summary(), "指定日期同步完成") { + t.Fatalf("报告没有标明指定日期同步:%+v / %s", report, report.Summary()) + } + at, _, err := repository.GetSybLastSyncedAt(db) + if err != nil { + t.Fatalf("读取同步游标失败: %v", err) + } + if at != original { + t.Fatalf("局部历史补拉不应推进游标,原值 %q,实际 %q", original, at) + } +} + +func TestRunSybSyncWithOptions_完整覆盖自动范围才推进游标(t *testing.T) { + srv := fakeSybServer(t, nil, 0) + defer srv.Close() + db := newSyncTestDB(t) + client, _ := syb.New(srv.URL) + const original = "2026-07-20T06:30:00Z" // UTC+8 仍是 7 月 20 日 + if err := repository.SetSybLastSyncedAt(db, original); err != nil { + t.Fatalf("准备同步游标失败: %v", err) + } + + report := RunSybSyncWithOptions(context.Background(), db, client, + config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}, + time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC), + SybSyncOptions{From: "2026-07-20", To: "2026-07-28"}) + if report.Err != nil { + t.Fatalf("完整范围同步失败: %v", report.Err) + } + at, _, err := repository.GetSybLastSyncedAt(db) + if err != nil { + t.Fatalf("读取同步游标失败: %v", err) + } + if at == original || at == "" { + t.Fatalf("完整覆盖自动范围后应该推进游标,实际仍为 %q", at) + } +} + func TestRunSybSync_数量小于等于0被跳过并计入报告(t *testing.T) { srv := fakeSybServer(t, []fakeStock{ {ID: 1, Code: "A", Details: []fakeDetail{ diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 7d8a12d..bc50512 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -187,6 +187,15 @@ tr.empty small { color: #aaa; } width: 100%; max-width: 720px; } +.modal-narrow { max-width: 520px; } +.field-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +@media (max-width: 560px) { + .field-row { grid-template-columns: 1fr; } +} .modal-head { display: flex; align-items: center; @@ -255,6 +264,13 @@ input[type="text"] { border: 1px solid #ccd1d6; border-radius: 3px; } +input[type="date"] { + width: 100%; + padding: 5px 8px; + border: 1px solid #ccd1d6; + border-radius: 3px; + background: #fff; +} input.wide { width: 100%; } select { diff --git a/admin/static/js/app.js b/admin/static/js/app.js index cd9de60..09421fc 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -89,13 +89,18 @@ 散到前端就没人维护得住了。 */ function openModal(modal) { + modal._returnFocus = document.activeElement; modal.hidden = false; - var focusable = modal.querySelector("input, button"); + var focusable = modal.querySelector("[autofocus]") || + modal.querySelector("input, button"); if (focusable) focusable.focus(); } function closeModal(modal) { modal.hidden = true; + if (modal._returnFocus && modal._returnFocus.focus) { + modal._returnFocus.focus(); + } } function setupModals() { diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index 616b56b..e921fec 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -5,9 +5,11 @@
- +
+ +
@@ -28,6 +30,47 @@
+{{/* 指定日期只用于补拉历史数据。日常增量仍走工具栏第一个“同步”按钮, + 避免操作员忘记清掉旧日期、下一次又误跑同一段历史数据。 */}} + + {{if .ConfigProblem}}

顺运宝配置有问题:{{.ConfigProblem}} @@ -118,6 +161,11 @@

+ {{/* 指定日期同步遇到登录时必须把范围带过来;为空就是普通增量同步。 */}} + {{if .HasSyncRange}} + + + {{end}}