diff --git a/past-go/internal/handler/checkin.go b/past-go/internal/handler/checkin.go index ee08957..e919352 100644 --- a/past-go/internal/handler/checkin.go +++ b/past-go/internal/handler/checkin.go @@ -346,31 +346,35 @@ func checkinDetail(c *httpapi.Ctx) error { } uid := c.UID() decorateCheckin(item, uid, false) - td, mon, ms, ys := today(), weekMonday(), monthStart(), yearStart() - logs, err := c.DB.Query("SELECT cl.*, u.nickname, u.avatar FROM checkin_logs cl LEFT JOIN users u ON cl.user_id = u.id WHERE cl.checkin_id = ? ORDER BY cl.created_at DESC", id) + page, pageSize, offset := pageParams(c, 20, 100) + logs, err := c.DB.Query("SELECT cl.*, u.nickname, u.avatar FROM checkin_logs cl LEFT JOIN users u ON cl.user_id = u.id WHERE cl.checkin_id = ? ORDER BY cl.created_at DESC, cl.id DESC LIMIT ? OFFSET ?", id, pageSize, offset) if err != nil { return err } - var myT, myW, myM, myY, pT, pW, pM, pY int - for _, l := range logs { - d, _ := first10(l["created_at"]) - mine := l.Int("user_id") == uid - inc := func(cond bool, m, p *int) { - if cond { - if mine { - *m++ - } else { - *p++ - } - } + // 统计覆盖全部记录,不能随列表分页改变。 + stats, err := c.DB.Query(`SELECT user_id, COUNT(*) AS total, + SUM(CASE WHEN DATE(created_at) = ? THEN 1 ELSE 0 END) AS today_count, + SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS week_count, + SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS month_count, + SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS year_count + FROM checkin_logs WHERE checkin_id = ? GROUP BY user_id`, today(), weekMonday(), monthStart(), yearStart(), id) + if err != nil { + return err + } + var total, myT, myW, myM, myY, pT, pW, pM, pY int64 + for _, row := range stats { + total += row.Int("total") + if row.Int("user_id") == uid { + myT, myW, myM, myY = row.Int("today_count"), row.Int("week_count"), row.Int("month_count"), row.Int("year_count") + } else { + pT += row.Int("today_count") + pW += row.Int("week_count") + pM += row.Int("month_count") + pY += row.Int("year_count") } - inc(d == td, &myT, &pT) - inc(d >= mon, &myW, &pW) - inc(d >= ms, &myM, &pM) - inc(d >= ys, &myY, &pY) } return c.OK(M{ - "item": item, "logs": rows(logs), + "item": item, "logs": rows(logs), "total": total, "page": page, "page_size": pageSize, "has_more": offset+int64(len(logs)) < total, "my_today": myT, "my_week": myW, "my_month": myM, "my_year": myY, "partner_today": pT, "partner_week": pW, "partner_month": pM, "partner_year": pY, }) diff --git a/past-go/internal/handler/checkin_pagination_test.go b/past-go/internal/handler/checkin_pagination_test.go new file mode 100644 index 0000000..ce465c6 --- /dev/null +++ b/past-go/internal/handler/checkin_pagination_test.go @@ -0,0 +1,44 @@ +package handler + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "past/internal/testdb" + "strings" + "testing" +) + +func TestCheckinDetailPaginatesWithoutTruncatingStats(t *testing.T) { + for _, page := range []int64{1, 2, 3, 4} { + t.Run(fmt.Sprint(page), func(t *testing.T) { + logs := [][]driver.Value{{int64(50 - page), int64(1), "2026-10-11 09:00:00"}} + if page == 4 { + logs = nil + } + c, out, _ := permissionCtx(t, "GET", fmt.Sprintf("/api/checkinDetail?id=9&page=%d&page_size=20", page), "", []testdb.Step{ + uidStep(1), cidStep(10), + {Match: "SELECT user1_id, user2_id FROM couples", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(2)}}}, + {Match: "c.created_by IN (?, ?)", Args: []any{int64(9), int64(1), int64(2)}, Columns: []string{"id", "created_by", "frequency", "type"}, Rows: [][]driver.Value{{int64(9), int64(1), "daily", "both"}}}, + {Match: "ORDER BY cl.created_at DESC, cl.id DESC LIMIT ? OFFSET ?", Args: []any{int64(9), int64(20), (page - 1) * 20}, Columns: []string{"id", "user_id", "created_at"}, Rows: logs}, + {Match: "GROUP BY user_id", Check: func(query string, args []any) error { + if strings.Contains(query, "LIMIT") { + return fmt.Errorf("stats must not be paginated") + } + return nil + }, Columns: []string{"user_id", "total", "today_count", "week_count", "month_count", "year_count"}, Rows: [][]driver.Value{{int64(1), int64(23), int64(1), int64(3), int64(7), int64(23)}, {int64(2), int64(18), int64(2), int64(4), int64(8), int64(18)}}}, + }) + checkinDetail(c) + if out.Code != 200 { + t.Fatalf("code=%d body=%s", out.Code, out.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(out.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["total"] != float64(41) || got["my_year"] != float64(23) || got["partner_year"] != float64(18) || got["has_more"] != (page < 3) { + t.Fatalf("unexpected response: %s", out.Body.String()) + } + }) + } +} diff --git a/weapp/pages/checkin-detail/checkin-detail.js b/weapp/pages/checkin-detail/checkin-detail.js index bc526e1..3c67d8d 100644 --- a/weapp/pages/checkin-detail/checkin-detail.js +++ b/weapp/pages/checkin-detail/checkin-detail.js @@ -7,7 +7,7 @@ const { requestSubscribeAfterAction } = require('../../utils/subscribe'); const COOLDOWN = 5000; Page(withDataCache({ onResize: function() { this.setData(getLayout()); }, - data: { loading: true, loadError: false, item: null, logs: [], freqLabel: '', typeLabel: '', myStreak: 0, partnerStreak: 0, showCheckModal: false, checkPhoto: '', checkNote: '', lastActionTime: 0, detailId: 0, myAvatar: '', partnerAvatar: '', myName: '我', partnerName: 'TA' }, + data: { loading: true, loadError: false, item: null, logs: [], logsPage: 1, logsTotal: 0, logsHasMore: false, logsLoading: false, logsError: false, freqLabel: '', typeLabel: '', myStreak: 0, partnerStreak: 0, showCheckModal: false, checkPhoto: '', checkNote: '', lastActionTime: 0, detailId: 0, myAvatar: '', partnerAvatar: '', myName: '我', partnerName: 'TA' }, onLoad: function(options) { this.setData(getLayout()); @@ -57,9 +57,11 @@ Page(withDataCache({ loadDetail: async function(id) { if (this._pageLoading) return; this._pageLoading = true; + this._logsGeneration = (this._logsGeneration || 0) + 1; + this.setData({ logsLoading: false, logsError: false }); this.setData({loading: !this._hasCachedData, loadError:false}); try { - var data = await api.checkinDetail(id); + var data = await api.checkinDetail(id, { page: 1, page_size: 20 }); var item = data.item || {}; var myUid = wx.getStorageSync('userId'); var freqLabels = { daily: '每天', weekly: '每周', monthly: '每月', once: '一次', unlimited: '不限' }; @@ -73,11 +75,7 @@ Page(withDataCache({ var partnerAvatar = this.data.partnerAvatar; var myName = this.data.myName; var partnerName = this.data.partnerName; - var logs = (data.logs || []).map(function(l) { - l.time = l.created_at ? l.created_at.substring(0, 16).replace('T', ' ') : ''; - l.photoUrl = l.photo ? getImageUrl(l.photo) : ''; - return l; - }); + var logs = this.formatLogs(data.logs || []); // 根据打卡权限类型映射统计数据 var isCreator = (myUid == item.created_by); var showMyStats = (item.type == 'both') || (item.type == 'first'); @@ -93,6 +91,8 @@ Page(withDataCache({ var pageData = { item: item, logs: logs, + logsPage: 1, logsTotal: Number(data.total) || logs.length, + logsHasMore: data.has_more === true, logsError: false, freqLabel: freqLabel, typeLabel: typeLabel, myStreak: (item.type == 'both') ? (item.my_streak || 0) : checkinStreak, @@ -118,6 +118,37 @@ Page(withDataCache({ finally { this._pageLoading = false; this.setData({loading:false}); } }, + formatLogs: function(logs) { + return logs.map(function(log) { + return Object.assign({}, log, { + time: log.created_at ? log.created_at.substring(0, 16).replace('T', ' ') : '', + photoUrl: log.photo ? getImageUrl(log.photo) : '' + }); + }); + }, + loadMoreLogs: async function() { + if (this._pageLoading || this.data.loading || this.data.loadError || this.data.logsLoading || !this.data.logsHasMore) return; + var generation = this._logsGeneration; + var page = this.data.logsPage + 1; + this.setData({ logsLoading: true, logsError: false }); + try { + var data = await api.checkinDetail(this.data.detailId, { page: page, page_size: 20 }); + if (generation !== this._logsGeneration) return; + var seen = new Set(this.data.logs.map(function(log) { return String(log.id); })); + var added = this.formatLogs(data.logs || []).filter(function(log) { + var id = String(log.id); + if (seen.has(id)) return false; + seen.add(id); return true; + }); + this.setData({ logs: this.data.logs.concat(added), logsPage: page, + logsTotal: Number(data.total) || 0, logsHasMore: data.has_more === true }); + } catch (e) { + if (generation === this._logsGeneration) this.setData({ logsError: true }); + } finally { + if (generation === this._logsGeneration) this.setData({ logsLoading: false }); + } + }, + // ===== 打卡操作 ===== showCheckinModal: function() { var now = Date.now(); diff --git a/weapp/pages/checkin-detail/checkin-detail.wxml b/weapp/pages/checkin-detail/checkin-detail.wxml index 57bfc09..5aec539 100644 --- a/weapp/pages/checkin-detail/checkin-detail.wxml +++ b/weapp/pages/checkin-detail/checkin-detail.wxml @@ -2,7 +2,7 @@ - + @@ -92,7 +92,7 @@ - 打卡记录 + 打卡记录 · {{logsTotal}} 条 @@ -116,6 +116,12 @@ 📭 暂无打卡记录 + + 正在加载… + 加载失败,点击重试 + 加载更多 + 已加载全部记录 + diff --git a/weapp/pages/checkin-detail/checkin-detail.wxss b/weapp/pages/checkin-detail/checkin-detail.wxss index fafc863..658d853 100644 --- a/weapp/pages/checkin-detail/checkin-detail.wxss +++ b/weapp/pages/checkin-detail/checkin-detail.wxss @@ -22,3 +22,5 @@ @import '../../styles/button-theme.wxss'; @import '../../styles/typography.wxss'; + +.logs-footer { padding:28rpx 0; text-align:center; color:#85776D; font-size:23rpx; } diff --git a/weapp/tests/checkin-pagination.test.cjs b/weapp/tests/checkin-pagination.test.cjs new file mode 100644 index 0000000..d3f04b0 --- /dev/null +++ b/weapp/tests/checkin-pagination.test.cjs @@ -0,0 +1,37 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const path=require('node:path'); +const load=require('./load-module.cjs'); +function setup(checkinDetail){ + let definition; + load({Page:p=>definition=p,wx:{getStorageSync:()=>1},console,Date,Promise,Set},{ + '../../utils/page-cache':{withDataCache:p=>p},'../../utils/layout':{getLayout:()=>({})}, + '../../utils/request':{api:{checkinDetail},getImageUrl:v=>v}, + '../../utils/util':{createSwipeBack:()=>({})},'../../utils/subscribe':{requestSubscribeAfterAction(){}} + })(path.join(__dirname,'../pages/checkin-detail/checkin-detail.js')); + const page={...definition,data:JSON.parse(JSON.stringify(definition.data)),setData(patch){Object.assign(this.data,patch);}}; + page.data.detailId=9;return page; +} +const first={item:{id:9,type:'both',created_by:1},logs:[{id:3,created_at:'2026-10-11 09:00:00'}],total:3,has_more:true,my_year:99}; +test('first page resets records; next page appends once without replacing full stats',async()=>{ + let calls=[];let release; + const page=setup((id,params)=>{calls.push(params);return params.page===1?Promise.resolve(first):new Promise(resolve=>release=resolve);}); + await page.loadDetail(9);const pending=page.loadMoreLogs();await page.loadMoreLogs(); + assert.equal(calls.length,2);assert.equal(calls[1].page,2); + release({logs:[{id:3},{id:2},{id:1}],total:3,has_more:false,my_year:1});await pending; + assert.deepEqual(page.data.logs.map(x=>x.id),[3,2,1]);assert.equal(page.data.myYear,99); + await page.loadMoreLogs();assert.equal(calls.length,2); + await page.loadDetail(9);assert.equal(page.data.logs.length,1);assert.equal(page.data.logsPage,1); +}); +test('failed next page preserves list and retries the same page',async()=>{ + let fail=true;const calls=[]; + const page=setup(async(id,params)=>{calls.push(params.page);if(params.page===1)return first;if(fail)throw Error('offline');return {logs:[{id:2}],total:3,has_more:true};}); + await page.loadDetail(9);await page.loadMoreLogs();assert.equal(page.data.logsPage,1);assert.equal(page.data.logsError,true);assert.equal(page.data.logs.length,1); + fail=false;await page.loadMoreLogs();assert.deepEqual(calls,[1,2,2]);assert.equal(page.data.logsError,false); +}); +test('an old next-page response cannot append after refreshed first page',async()=>{ + let release;const page=setup(async(id,params)=>params.page===1?first:new Promise(resolve=>release=resolve)); + await page.loadDetail(9);const pending=page.loadMoreLogs();await page.loadDetail(9); + release({logs:[{id:1}],has_more:false});await pending; + assert.deepEqual(page.data.logs.map(x=>x.id),[3]);assert.equal(page.data.logsPage,1); +}); diff --git a/weapp/utils/request.js b/weapp/utils/request.js index 401e000..3adb60e 100644 --- a/weapp/utils/request.js +++ b/weapp/utils/request.js @@ -369,7 +369,7 @@ const api = { checkinAdd: (data) => request('checkinAdd', data), checkinDo: (data) => request('checkinDo', data), checkinStats: () => request('checkinStats', null, 'GET'), - checkinDetail: (id) => request('checkinDetail', { id: id }, 'GET'), + checkinDetail: (id, pagination = {}) => request('checkinDetail', { ...pagination, id: id }, 'GET'), checkinDelete: (data) => request('checkinDelete', data), // 背景图