为打卡详情记录增加分页并保持全量统计
This commit is contained in:
@@ -346,31 +346,35 @@ func checkinDetail(c *httpapi.Ctx) error {
|
|||||||
}
|
}
|
||||||
uid := c.UID()
|
uid := c.UID()
|
||||||
decorateCheckin(item, uid, false)
|
decorateCheckin(item, uid, false)
|
||||||
td, mon, ms, ys := today(), weekMonday(), monthStart(), yearStart()
|
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", id)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var myT, myW, myM, myY, pT, pW, pM, pY int
|
// 统计覆盖全部记录,不能随列表分页改变。
|
||||||
for _, l := range logs {
|
stats, err := c.DB.Query(`SELECT user_id, COUNT(*) AS total,
|
||||||
d, _ := first10(l["created_at"])
|
SUM(CASE WHEN DATE(created_at) = ? THEN 1 ELSE 0 END) AS today_count,
|
||||||
mine := l.Int("user_id") == uid
|
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS week_count,
|
||||||
inc := func(cond bool, m, p *int) {
|
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS month_count,
|
||||||
if cond {
|
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) AS year_count
|
||||||
if mine {
|
FROM checkin_logs WHERE checkin_id = ? GROUP BY user_id`, today(), weekMonday(), monthStart(), yearStart(), id)
|
||||||
*m++
|
if err != nil {
|
||||||
} else {
|
return err
|
||||||
*p++
|
}
|
||||||
}
|
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{
|
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,
|
"my_today": myT, "my_week": myW, "my_month": myM, "my_year": myY,
|
||||||
"partner_today": pT, "partner_week": pW, "partner_month": pM, "partner_year": pY,
|
"partner_today": pT, "partner_week": pW, "partner_month": pM, "partner_year": pY,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ const { requestSubscribeAfterAction } = require('../../utils/subscribe');
|
|||||||
const COOLDOWN = 5000;
|
const COOLDOWN = 5000;
|
||||||
Page(withDataCache({
|
Page(withDataCache({
|
||||||
onResize: function() { this.setData(getLayout()); },
|
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) {
|
onLoad: function(options) {
|
||||||
this.setData(getLayout());
|
this.setData(getLayout());
|
||||||
@@ -57,9 +57,11 @@ Page(withDataCache({
|
|||||||
loadDetail: async function(id) {
|
loadDetail: async function(id) {
|
||||||
if (this._pageLoading) return;
|
if (this._pageLoading) return;
|
||||||
this._pageLoading = true;
|
this._pageLoading = true;
|
||||||
|
this._logsGeneration = (this._logsGeneration || 0) + 1;
|
||||||
|
this.setData({ logsLoading: false, logsError: false });
|
||||||
this.setData({loading: !this._hasCachedData, loadError:false});
|
this.setData({loading: !this._hasCachedData, loadError:false});
|
||||||
try {
|
try {
|
||||||
var data = await api.checkinDetail(id);
|
var data = await api.checkinDetail(id, { page: 1, page_size: 20 });
|
||||||
var item = data.item || {};
|
var item = data.item || {};
|
||||||
var myUid = wx.getStorageSync('userId');
|
var myUid = wx.getStorageSync('userId');
|
||||||
var freqLabels = { daily: '每天', weekly: '每周', monthly: '每月', once: '一次', unlimited: '不限' };
|
var freqLabels = { daily: '每天', weekly: '每周', monthly: '每月', once: '一次', unlimited: '不限' };
|
||||||
@@ -73,11 +75,7 @@ Page(withDataCache({
|
|||||||
var partnerAvatar = this.data.partnerAvatar;
|
var partnerAvatar = this.data.partnerAvatar;
|
||||||
var myName = this.data.myName;
|
var myName = this.data.myName;
|
||||||
var partnerName = this.data.partnerName;
|
var partnerName = this.data.partnerName;
|
||||||
var logs = (data.logs || []).map(function(l) {
|
var logs = this.formatLogs(data.logs || []);
|
||||||
l.time = l.created_at ? l.created_at.substring(0, 16).replace('T', ' ') : '';
|
|
||||||
l.photoUrl = l.photo ? getImageUrl(l.photo) : '';
|
|
||||||
return l;
|
|
||||||
});
|
|
||||||
// 根据打卡权限类型映射统计数据
|
// 根据打卡权限类型映射统计数据
|
||||||
var isCreator = (myUid == item.created_by);
|
var isCreator = (myUid == item.created_by);
|
||||||
var showMyStats = (item.type == 'both') || (item.type == 'first');
|
var showMyStats = (item.type == 'both') || (item.type == 'first');
|
||||||
@@ -93,6 +91,8 @@ Page(withDataCache({
|
|||||||
var pageData = {
|
var pageData = {
|
||||||
item: item,
|
item: item,
|
||||||
logs: logs,
|
logs: logs,
|
||||||
|
logsPage: 1, logsTotal: Number(data.total) || logs.length,
|
||||||
|
logsHasMore: data.has_more === true, logsError: false,
|
||||||
freqLabel: freqLabel,
|
freqLabel: freqLabel,
|
||||||
typeLabel: typeLabel,
|
typeLabel: typeLabel,
|
||||||
myStreak: (item.type == 'both') ? (item.my_streak || 0) : checkinStreak,
|
myStreak: (item.type == 'both') ? (item.my_streak || 0) : checkinStreak,
|
||||||
@@ -118,6 +118,37 @@ Page(withDataCache({
|
|||||||
finally { this._pageLoading = false; this.setData({loading:false}); }
|
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() {
|
showCheckinModal: function() {
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<launch-transition />
|
<launch-transition />
|
||||||
<!-- pages/checkin-detail/checkin-detail.wxml - 打卡详情 -->
|
<!-- pages/checkin-detail/checkin-detail.wxml - 打卡详情 -->
|
||||||
<app-nav title="打卡详情" back="{{true}}" />
|
<app-nav title="打卡详情" back="{{true}}" />
|
||||||
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}" style="height:100vh;" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
|
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}" bindscrolltolower="loadMoreLogs" lower-threshold="120" style="height:100vh;" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
|
||||||
<view class="c-body">
|
<view class="c-body">
|
||||||
<page-state wx:if="{{loading}}" kind="loading" title="正在加载详情" /><page-state wx:elif="{{loadError}}" kind="error" title="详情加载失败" action="重试" bind:action="retryDetail" />
|
<page-state wx:if="{{loading}}" kind="loading" title="正在加载详情" /><page-state wx:elif="{{loadError}}" kind="error" title="详情加载失败" action="重试" bind:action="retryDetail" />
|
||||||
<block wx:else>
|
<block wx:else>
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
<button class="detail-checkin-btn" wx:if="{{item.can_checkin}}" bindtap="showCheckinModal" hover-class="none">记录这一次</button>
|
<button class="detail-checkin-btn" wx:if="{{item.can_checkin}}" bindtap="showCheckinModal" hover-class="none">记录这一次</button>
|
||||||
|
|
||||||
<!-- 打卡记录列表 -->
|
<!-- 打卡记录列表 -->
|
||||||
<text class="section-title">打卡记录</text>
|
<text class="section-title">打卡记录 · {{logsTotal}} 条</text>
|
||||||
<view class="log-list" wx:if="{{logs.length > 0}}">
|
<view class="log-list" wx:if="{{logs.length > 0}}">
|
||||||
<view class="log-item" wx:for="{{logs}}" wx:key="id">
|
<view class="log-item" wx:for="{{logs}}" wx:key="id">
|
||||||
<view class="log-dot" style="background:{{item.avatar ? '#B74768' : '#9C27B0'}};"></view>
|
<view class="log-dot" style="background:{{item.avatar ? '#B74768' : '#9C27B0'}};"></view>
|
||||||
@@ -116,6 +116,12 @@
|
|||||||
<text>📭</text>
|
<text>📭</text>
|
||||||
<text>暂无打卡记录</text>
|
<text>暂无打卡记录</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="logs-footer" wx:if="{{logs.length > 0}}">
|
||||||
|
<text wx:if="{{logsLoading}}">正在加载…</text>
|
||||||
|
<text wx:elif="{{logsError}}" bindtap="loadMoreLogs">加载失败,点击重试</text>
|
||||||
|
<text wx:elif="{{logsHasMore}}" bindtap="loadMoreLogs">加载更多</text>
|
||||||
|
<text wx:else>已加载全部记录</text>
|
||||||
|
</view>
|
||||||
</block>
|
</block>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,5 @@
|
|||||||
@import '../../styles/button-theme.wxss';
|
@import '../../styles/button-theme.wxss';
|
||||||
|
|
||||||
@import '../../styles/typography.wxss';
|
@import '../../styles/typography.wxss';
|
||||||
|
|
||||||
|
.logs-footer { padding:28rpx 0; text-align:center; color:#85776D; font-size:23rpx; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -369,7 +369,7 @@ const api = {
|
|||||||
checkinAdd: (data) => request('checkinAdd', data),
|
checkinAdd: (data) => request('checkinAdd', data),
|
||||||
checkinDo: (data) => request('checkinDo', data),
|
checkinDo: (data) => request('checkinDo', data),
|
||||||
checkinStats: () => request('checkinStats', null, 'GET'),
|
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),
|
checkinDelete: (data) => request('checkinDelete', data),
|
||||||
|
|
||||||
// 背景图
|
// 背景图
|
||||||
|
|||||||
Reference in New Issue
Block a user