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),
// 背景图