diff --git a/past-go/internal/handler/album.go b/past-go/internal/handler/album.go index 1c4b73a..8f9f5e5 100644 --- a/past-go/internal/handler/album.go +++ b/past-go/internal/handler/album.go @@ -37,9 +37,14 @@ func coupleSpaceID(c *httpapi.Ctx, cid int64) string { func albumList(c *httpapi.Ctx) error { ids := coupleUserIds(c, 0) if len(ids) == 0 { - return c.OK(demoData("album")) + photoTotal := int64(0) + for _, album := range demoData("album") { + photoTotal += store.ToInt(album["photo_count"]) + } + return demoListResponse(c, "album", M{"photo_total": photoTotal}) } - items, err := c.DB.Query("SELECT a.*, (SELECT COUNT(*) FROM photos WHERE album_id = a.id) as photo_count, (SELECT p.path FROM photos p WHERE p.album_id = a.id ORDER BY p.created_at DESC LIMIT 1) as latest_photo FROM albums a WHERE a.created_by IN (?, ?) ORDER BY a.sort_order ASC, a.created_at DESC", ids[0], ids[1]) + suffix, window := listWindow(c) + items, err := c.DB.Query("SELECT a.*, (SELECT COUNT(*) FROM photos WHERE album_id = a.id) as photo_count, (SELECT p.path FROM photos p WHERE p.album_id = a.id ORDER BY p.created_at DESC LIMIT 1) as latest_photo FROM albums a WHERE a.created_by IN (?, ?) ORDER BY a.sort_order ASC, a.created_at DESC, a.id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...) if err != nil { return err } @@ -48,7 +53,9 @@ func albumList(c *httpapi.Ctx) error { it["cover"] = it["latest_photo"] } } - return c.OK(rows(items)) + total := countC(c, "SELECT COUNT(*) as c FROM albums WHERE created_by IN (?, ?)", ids[0], ids[1]) + photoTotal := countC(c, "SELECT COUNT(*) as c FROM photos p JOIN albums a ON p.album_id=a.id WHERE a.created_by IN (?, ?)", ids[0], ids[1]) + return listResponse(c, items, total, M{"photo_total": photoTotal}) } func albumAdd(c *httpapi.Ctx) error { @@ -185,13 +192,14 @@ func photos(c *httpapi.Ctx) error { } ids := coupleUserIds(c, 0) if len(ids) == 0 { - return c.OK([]any{}) + return listResponse(c, nil, 0, nil) } - items, err := c.DB.Query("SELECT p.* FROM photos p JOIN albums a ON p.album_id = a.id WHERE a.created_by IN (?, ?) AND p.album_id = ? ORDER BY p.sort_order ASC, p.created_at DESC", ids[0], ids[1], albumID) + suffix, window := listWindow(c) + items, err := c.DB.Query("SELECT p.* FROM photos p JOIN albums a ON p.album_id = a.id WHERE a.created_by IN (?, ?) AND p.album_id = ? ORDER BY p.sort_order ASC, p.created_at DESC, p.id DESC"+suffix, append([]any{ids[0], ids[1], albumID}, window...)...) if err != nil { return err } - return c.OK(rows(items)) + return listResponse(c, items, countC(c, "SELECT COUNT(*) as c FROM photos p JOIN albums a ON p.album_id=a.id WHERE a.created_by IN (?, ?) AND p.album_id=?", ids[0], ids[1], albumID), nil) } // setFirstCover 相册第一张照片时自动设为封面 diff --git a/past-go/internal/handler/checkin.go b/past-go/internal/handler/checkin.go index e919352..226b39a 100644 --- a/past-go/internal/handler/checkin.go +++ b/past-go/internal/handler/checkin.go @@ -156,10 +156,14 @@ func decorateCheckin(item store.Row, uid int64, withAvatar bool) { func checkinList(c *httpapi.Ctx) error { ids := coupleUserIds(c, 0) if len(ids) == 0 { + if c.QHas("page") || c.QHas("page_size") { + return demoListResponse(c, "checkin", M{"week_both_count": 0, "checkin_log_total": 0}) + } return c.OK(M{"items": demoData("checkin"), "week_both_count": 0, "total": 5}) } uid := c.UID() - items, err := c.DB.Query("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.created_by IN (?, ?) AND NOT EXISTS (SELECT 1 FROM smoking_accounts sa WHERE sa.checkin_id = c.id) ORDER BY c.sort_order ASC, c.created_at DESC", ids[0], ids[1]) + suffix, window := listWindow(c) + items, err := c.DB.Query("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.created_by IN (?, ?) AND NOT EXISTS (SELECT 1 FROM smoking_accounts sa WHERE sa.checkin_id = c.id) ORDER BY c.sort_order ASC, c.created_at DESC, c.id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...) if err != nil { return err } @@ -171,6 +175,9 @@ func checkinList(c *httpapi.Ctx) error { if cid := c.CoupleID(); cid != 0 { logTotal = countC(c, "SELECT COUNT(*) as c FROM checkin_logs WHERE couple_id = ?", cid) } + if c.QHas("page") || c.QHas("page_size") { + return listResponse(c, items, total, M{"week_both_count": 0, "checkin_log_total": logTotal}) + } return c.OK(M{"items": rows(items), "week_both_count": 0, "total": total, "checkin_log_total": logTotal}) } diff --git a/past-go/internal/handler/diary.go b/past-go/internal/handler/diary.go index 692a9c1..9b3d40f 100644 --- a/past-go/internal/handler/diary.go +++ b/past-go/internal/handler/diary.go @@ -251,14 +251,15 @@ func diaryCommentList(c *httpapi.Ctx) error { if err != nil { return err } - comments, err := c.DB.Query("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.diary_id = ? AND c.couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?)) ORDER BY c.created_at ASC", diaryID, ids[0], ids[1]) + suffix, window := listWindow(c) + comments, err := c.DB.Query("SELECT c.*, u.nickname, u.avatar FROM diary_comments c LEFT JOIN users u ON c.user_id = u.id WHERE c.diary_id = ? AND c.couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?)) ORDER BY c.created_at DESC, c.id DESC"+suffix, append([]any{diaryID, ids[0], ids[1]}, window...)...) if err != nil { return err } for _, cm := range comments { cm["avatar_url"] = avatarURL(cm.Str("avatar")) } - return c.OK(rows(comments)) + return listResponse(c, comments, countC(c, "SELECT COUNT(*) as c FROM diary_comments WHERE diary_id=? AND couple_id IN (SELECT couple_id FROM users WHERE id IN (?, ?))", diaryID, ids[0], ids[1]), nil) } func diaryCommentAdd(c *httpapi.Ctx) error { diff --git a/past-go/internal/handler/list_pagination.go b/past-go/internal/handler/list_pagination.go new file mode 100644 index 0000000..6345c5e --- /dev/null +++ b/past-go/internal/handler/list_pagination.go @@ -0,0 +1,47 @@ +package handler + +import ( + "past/internal/httpapi" + "past/internal/store" +) + +// Keep legacy response shapes for released clients without pagination parameters. +func listWindow(c *httpapi.Ctx) (string, []any) { + if !c.QHas("page") && !c.QHas("page_size") { + return "", nil + } + _, size, offset := pageParams(c, 20, 100) + return " LIMIT ? OFFSET ?", []any{size, offset} +} +func listResponse(c *httpapi.Ctx, items []store.Row, total int64, extra M) error { + if !c.QHas("page") && !c.QHas("page_size") { + return c.OK(rows(items)) + } + page, size, offset := pageParams(c, 20, 100) + result := M{"items": rows(items), "total": total, "page": page, "page_size": size, "has_more": offset+int64(len(items)) < total} + for k, v := range extra { + result[k] = v + } + return c.OK(result) +} +func demoListResponse(c *httpapi.Ctx, module string, extra M) error { + demo := demoData(module) + items := make([]store.Row, len(demo)) + for i, item := range demo { + items[i] = store.Row(item) + } + total := int64(len(items)) + if c.QHas("page") || c.QHas("page_size") { + _, size, offset := pageParams(c, 20, 100) + if offset >= total { + items = nil + } else { + end := offset + size + if end > total { + end = total + } + items = items[offset:end] + } + } + return listResponse(c, items, total, extra) +} diff --git a/past-go/internal/handler/list_pagination_test.go b/past-go/internal/handler/list_pagination_test.go new file mode 100644 index 0000000..f7c62b1 --- /dev/null +++ b/past-go/internal/handler/list_pagination_test.go @@ -0,0 +1,71 @@ +package handler + +import ( + "database/sql/driver" + "encoding/json" + "past/internal/httpapi" + "past/internal/testdb" + "strings" + "testing" +) + +func TestPagedListsBoundQueriesAndKeepMemberScope(t *testing.T) { + for _, tt := range []struct { + name, uri, table string + handler httpapi.HandlerFunc + args []any + extra []testdb.Step + }{ + {"songs", "/api/songsList", "songs", songsList, []any{int64(1), int64(3), int64(20), int64(20)}, nil}, + {"albums", "/api/albumList", "albums", albumList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "FROM photos p JOIN albums a", Columns: []string{"c"}, Rows: [][]driver.Value{{int64(83)}}}}}, + {"photos", "/api/photos?album_id=9", "photos", photos, []any{int64(1), int64(3), "9", int64(20), int64(20)}, nil}, + {"timeline", "/api/timelineList", "timeline", timelineList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "event_date >= CURRENT_DATE", Columns: []string{"id", "event_date"}, Rows: [][]driver.Value{{int64(50), "2026-12-01"}}}}}, + {"checkins", "/api/checkinList", "checkins", checkinList, []any{int64(1), int64(3), int64(20), int64(20)}, []testdb.Step{{Match: "FROM checkin_logs WHERE couple_id = ?", Args: []any{int64(22)}, Columns: []string{"c"}, Rows: [][]driver.Value{{int64(90)}}}}}, + {"comments", "/api/diaryCommentList?diary_id=9", "diary_comments", diaryCommentList, []any{int64(9), int64(1), int64(3), int64(20), int64(20)}, nil}, + } { + t.Run(tt.name, func(t *testing.T) { + uri := tt.uri + if strings.Contains(uri, "?") { + uri += "&" + } else { + uri += "?" + } + uri += "page=2&page_size=20" + steps := []testdb.Step{uidStep(1), cidStep(22), {Match: "SELECT user1_id, user2_id FROM couples", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(3)}}}, + {Match: "LIMIT ? OFFSET ?", Args: tt.args, Check: func(q string, _ []any) error { + if !strings.Contains(q, "IN (?, ?)") { + t.Errorf("missing member predicate: %s", q) + } + return nil + }, Columns: []string{"id"}, Rows: [][]driver.Value{{int64(21)}}}, + {Match: "SELECT COUNT(*) as c FROM " + tt.table, Columns: []string{"c"}, Rows: [][]driver.Value{{int64(41)}}}} + steps = append(steps, tt.extra...) + c, out, _ := permissionCtx(t, "GET", uri, "", steps) + tt.handler(c) + if out.Code != 200 { + t.Fatalf("status %d: %s", out.Code, out.Body.String()) + } + var result map[string]any + if err := json.Unmarshal(out.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result["total"] != float64(41) || result["page"] != float64(2) || result["has_more"] != true { + t.Fatalf("wrong pagination: %s", out.Body.String()) + } + if len(result["items"].([]any)) != 1 { + t.Fatal("wrong items") + } + if tt.name == "albums" && result["photo_total"] != float64(83) { + t.Fatal("photo total must cover all albums") + } + }) + } +} + +func TestListPaginationCapsPageSizeAndClampsPage(t *testing.T) { + c, _, _ := permissionCtx(t, "GET", "/api/list?page=-1&page_size=9999", "", nil) + suffix, args := listWindow(c) + if suffix != " LIMIT ? OFFSET ?" || args[0] != int64(100) || args[1] != int64(0) { + t.Fatalf("unsafe window %s %v", suffix, args) + } +} diff --git a/past-go/internal/handler/smoking.go b/past-go/internal/handler/smoking.go index 196e655..ea9c9fd 100644 --- a/past-go/internal/handler/smoking.go +++ b/past-go/internal/handler/smoking.go @@ -55,7 +55,7 @@ func smokingDelta(kind string, quantity, stock int64) (int64, error) { } func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) { - result := M{"stock": int64(0), "today_count": int64(0), "total_count": int64(0), "week_count": int64(0), "month_count": int64(0), "week_purchase_cents": int64(0), "month_purchase_cents": int64(0), "logs": []any{}, "daily": []any{}, "last_time": "", "user_id": uid, "read_only": uid != c.UID(), "available": true} + result := M{"stock": int64(0), "today_count": int64(0), "total_count": int64(0), "week_count": int64(0), "month_count": int64(0), "week_purchase_cents": int64(0), "month_purchase_cents": int64(0), "logs": []any{}, "logs_total": int64(0), "logs_page": int64(1), "logs_has_more": false, "daily": []any{}, "last_time": "", "user_id": uid, "read_only": uid != c.UID(), "available": true} owner, err := c.DB.QueryOne("SELECT nickname, username FROM users WHERE id = ?", uid) if err != nil { return nil, err @@ -97,11 +97,15 @@ func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) { } result["last_time"] = summary.Str("last_time") } - logs, err := c.DB.Query("SELECT id, kind, quantity, delta, balance, amount_cents, note, undone, created_at FROM smoking_entries WHERE user_id = ? ORDER BY id DESC LIMIT 50", aid) + page, size, offset := pageParams(c, 20, 100) + result["logs_page"] = page + result["logs_total"] = countC(c, "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id=?", aid) + logs, err := c.DB.Query("SELECT id, kind, quantity, delta, balance, amount_cents, note, undone, created_at FROM smoking_entries WHERE user_id = ? ORDER BY id DESC LIMIT ? OFFSET ?", aid, size, offset) if err != nil { return nil, err } result["logs"] = rows(logs) + result["logs_has_more"] = offset+int64(len(logs)) < store.ToInt(result["logs_total"]) daily, err := c.DB.Query("SELECT DATE_FORMAT(created_at,'%Y-%m-%d') AS day, COUNT(*) AS count FROM smoking_entries WHERE user_id = ? AND kind IN ('consume','receive_smoke') AND undone = 0 AND created_at >= DATE_SUB(CURRENT_DATE, INTERVAL 6 DAY) GROUP BY DATE_FORMAT(created_at,'%Y-%m-%d') ORDER BY day", aid) if err != nil { return nil, err diff --git a/past-go/internal/handler/smoking_stats_test.go b/past-go/internal/handler/smoking_stats_test.go index f007143..9508a12 100644 --- a/past-go/internal/handler/smoking_stats_test.go +++ b/past-go/internal/handler/smoking_stats_test.go @@ -35,7 +35,8 @@ func TestSmokingSnapshotWeeklyMonthlyStats(t *testing.T) { } return nil }, Columns: []string{"today_count", "total_count", "week_count", "month_count", "week_purchase_cents", "month_purchase_cents", "last_time"}, Rows: [][]driver.Value{{int64(1), int64(120), int64(7), int64(29), int64(3050), int64(12500), "2026-10-11 09:00:00"}}}, - testdb.Step{Match: "ORDER BY id DESC LIMIT 50", Columns: []string{"id"}}, + testdb.Step{Match: "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id=?", Args: []any{int64(2)}, Columns: []string{"c"}, Rows: [][]driver.Value{{int64(120)}}}, + testdb.Step{Match: "ORDER BY id DESC LIMIT ? OFFSET ?", Args: []any{int64(2), int64(20), int64(0)}, Columns: []string{"id"}}, testdb.Step{Match: "GROUP BY DATE_FORMAT", Columns: []string{"day", "count"}}, ) } diff --git a/past-go/internal/handler/songs.go b/past-go/internal/handler/songs.go index 94bd7e4..5e4b9d9 100644 --- a/past-go/internal/handler/songs.go +++ b/past-go/internal/handler/songs.go @@ -17,13 +17,14 @@ func registerSongs(r *httpapi.Router) { func songsList(c *httpapi.Ctx) error { ids := coupleUserIds(c, 0) if len(ids) == 0 { - return c.OK(demoData("songs")) + return demoListResponse(c, "songs", nil) } - items, err := c.DB.Query("SELECT s.*, u.nickname, u.avatar FROM songs s LEFT JOIN users u ON s.created_by = u.id WHERE s.created_by IN (?, ?) ORDER BY s.created_at DESC", ids[0], ids[1]) + suffix, window := listWindow(c) + items, err := c.DB.Query("SELECT s.*, u.nickname, u.avatar FROM songs s LEFT JOIN users u ON s.created_by = u.id WHERE s.created_by IN (?, ?) ORDER BY s.created_at DESC, s.id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...) if err != nil { return err } - return c.OK(rows(items)) + return listResponse(c, items, countC(c, "SELECT COUNT(*) as c FROM songs WHERE created_by IN (?, ?)", ids[0], ids[1]), nil) } func songText(c *httpapi.Ctx) string { diff --git a/past-go/internal/handler/timeline.go b/past-go/internal/handler/timeline.go index 728fd34..cf523e6 100644 --- a/past-go/internal/handler/timeline.go +++ b/past-go/internal/handler/timeline.go @@ -44,16 +44,22 @@ func checkTextOr400(c *httpapi.Ctx, text string) error { func timelineList(c *httpapi.Ctx) error { ids := coupleUserIds(c, 0) if len(ids) == 0 { - return c.OK(demoData("timeline")) + return demoListResponse(c, "timeline", nil) } - items, err := c.DB.Query("SELECT * FROM timeline WHERE created_by IN (?, ?) ORDER BY event_date DESC, sort_order ASC", ids[0], ids[1]) + suffix, window := listWindow(c) + items, err := c.DB.Query("SELECT * FROM timeline WHERE created_by IN (?, ?) ORDER BY event_date DESC, sort_order ASC, id DESC"+suffix, append([]any{ids[0], ids[1]}, window...)...) if err != nil { return err } for _, it := range items { it["images"] = jsonDecodeList(it["images"]) } - return c.OK(rows(items)) + total := countC(c, "SELECT COUNT(*) as c FROM timeline WHERE created_by IN (?, ?)", ids[0], ids[1]) + upcoming, err := c.DB.QueryOne("SELECT * FROM timeline WHERE created_by IN (?, ?) AND event_date >= CURRENT_DATE ORDER BY event_date ASC, id DESC LIMIT 1", ids[0], ids[1]) + if err != nil { + return err + } + return listResponse(c, items, total, M{"upcoming": upcoming}) } func timelineAdd(c *httpapi.Ctx) error { diff --git a/weapp/components/smoking-card/smoking-card.js b/weapp/components/smoking-card/smoking-card.js index e50ff4a..a419416 100644 --- a/weapp/components/smoking-card/smoking-card.js +++ b/weapp/components/smoking-card/smoking-card.js @@ -1,9 +1,10 @@ +const { mergeUnique } = require('../../utils/list-pagination'); const { api, getImageUrl } = require('../../utils/request'); const cache = require('../../utils/data-cache'); const titles = {consume:'抽一支 · 打卡',purchase:'买烟',receive:'别人递烟',receive_smoke:'收到并抽一支',adjust:'调整库存',give:'递给别人一支',undo:'撤销记录'}; Component({ properties: { detailed: Boolean }, - data: { loading:true, error:false, info:null, saving:false, sheet:false, mode:'purchase', quantity:'1', packSize:'20', amount:'', note:'', viewUserId:0, lastLabel:'还没有记录', daily:[], logs:[], periodStats:[], ownerName:'', ownerAvatar:'', ownerInitial:'我' }, + data: { loading:true, error:false, info:null, saving:false, sheet:false, mode:'purchase', quantity:'1', packSize:'20', amount:'', note:'', viewUserId:0, lastLabel:'还没有记录', daily:[], logs:[], periodStats:[], logsPage:1, logsTotal:0, logsHasMore:false, logsLoadingMore:false, logsMoreError:false, ownerName:'', ownerAvatar:'', ownerInitial:'我' }, lifetimes: { attached(){this.load();} }, pageLifetimes: { show(){this.load();} }, methods: { @@ -16,6 +17,7 @@ Component({ return this._loading; }, applyInfo(info) { + this._logsGeneration = (this._logsGeneration || 0) + 1; const logs=(info.logs||[]).map(log=>({...log,undone:log.undone === true || Number(log.undone) === 1,title:titles[log.kind]||log.kind,change:Number(log.delta)>0?'+'+log.delta:String(log.delta),time:String(log.created_at||'').slice(5,16),money:Number(log.amount_cents)>0?(Number(log.amount_cents)/100).toFixed(2):''})); let lastLabel='还没有记录'; if(info.last_time){const minutes=Math.max(0,Math.floor((Date.now()-new Date(String(info.last_time).replace(/-/g,'/')).getTime())/60000));lastLabel=minutes<60?'距上次 '+minutes+' 分钟':minutes<1440?'距上次 '+Math.floor(minutes/60)+' 小时':'距上次 '+Math.floor(minutes/1440)+' 天';} @@ -32,12 +34,23 @@ Component({ const owner=[profile.partner1,profile.partner2].find(user=>user && Number(user.id)===ownerId)||{}; const ownerName=owner.nickname||info.owner_name||owner.username||(info.read_only?'TA':'我'); const ownerAvatar=getImageUrl(owner.avatar||info.owner_avatar||''); - this.setData({info,logs,daily,periodStats,lastLabel,ownerName,ownerAvatar,ownerInitial:ownerName.slice(0,1),error:false,loading:false}); + this.setData({info,logs,daily,periodStats,logsPage:Number(info.logs_page)||1,logsTotal:Number(info.logs_total)||logs.length,logsHasMore:info.logs_has_more===true,logsLoadingMore:false,logsMoreError:false,lastLabel,ownerName,ownerAvatar,ownerInitial:ownerName.slice(0,1),error:false,loading:false}); + }, + async loadMoreLogs(){ + if(this._loading || this.data.loading || this.data.saving || this.data.logsLoadingMore || !this.data.logsHasMore)return; + const generation=this._logsGeneration,target=this.data.viewUserId,page=this.data.logsPage+1; + this.setData({logsLoadingMore:true,logsMoreError:false}); + try{ + const info=await api.smokingInfo(target,{page,page_size:20}); + if(generation!==this._logsGeneration || target!==this.data.viewUserId)return; + this.applyInfo({...info,logs:mergeUnique(this.data.info.logs||[],info.logs||[])}); + }catch(e){if(generation===this._logsGeneration)this.setData({logsMoreError:true});} + finally{if(generation===this._logsGeneration)this.setData({logsLoadingMore:false});} }, avatarFailed(){this.setData({ownerAvatar:''});}, retry(){this.load(true);}, openDetail(){if(!this.properties.detailed)wx.navigateTo({url:'/pages/smoking-detail/smoking-detail'});}, - async switchOwner(e){if(this.data.saving)return;this.setData({viewUserId:Number(e.currentTarget.dataset.user)||0,loading:true,info:null});if(this._loading)await this._loading;await this.load();}, + async switchOwner(e){if(this.data.saving)return;this._logsGeneration=(this._logsGeneration||0)+1;this.setData({viewUserId:Number(e.currentTarget.dataset.user)||0,loading:true,info:null});if(this._loading)await this._loading;await this.load();}, replenish(){if(this.data.saving)return;this.setData({sheet:true,mode:'purchase',quantity:'1',packSize:'20',amount:'',note:''});}, mode(e){if(this.data.saving)return;const mode=e.currentTarget.dataset.mode;this.setData({mode,quantity:mode==='adjust'?String(this.data.info.stock):'1',amount:'',note:''});}, input(e){if(!this.data.saving)this.setData({[e.currentTarget.dataset.field]:e.detail.value});}, diff --git a/weapp/components/smoking-card/smoking-card.wxml b/weapp/components/smoking-card/smoking-card.wxml index 2ad8ebc..71d0647 100644 --- a/weapp/components/smoking-card/smoking-card.wxml +++ b/weapp/components/smoking-card/smoking-card.wxml @@ -19,9 +19,10 @@ 购买金额按已填写金额累计,已撤销记录不计入 最近七天累计 {{info.total_count}} 支 {{item.count}}{{item.label}} - 库存与打卡流水最近 50 条 + 库存与打卡流水共 {{logsTotal}} 条 从补充库存开始,记录每一次消耗 {{ownerInitial}}{{ownerName}}{{item.title}}{{item.undone ? ' · 已撤销' : ''}}{{item.time}}{{item.money ? ' · ¥' + item.money : ''}}{{item.note}}{{item.change}} 支 · 余 {{item.balance}}撤销 + 正在加载…加载失败,点击重试加载更多流水已加载全部流水 diff --git a/weapp/components/smoking-card/smoking-card.wxss b/weapp/components/smoking-card/smoking-card.wxss index 2b28df0..d6c00ef 100644 --- a/weapp/components/smoking-card/smoking-card.wxss +++ b/weapp/components/smoking-card/smoking-card.wxss @@ -14,3 +14,5 @@ @import '../../styles/typography.wxss'; .smoke-periods{display:flex;gap:16rpx}.smoke-period{flex:1;min-width:0;padding:22rpx 18rpx;border:1rpx solid #F1DCE4;border-radius:20rpx;background:#FFF5F8}.smoke-period-head{display:flex;align-items:center;justify-content:space-between;gap:8rpx;font-size:25rpx;color:#64424D}.smoke-period-head text+text{font-size:18rpx;color:#A39386}.smoke-period-row{display:flex;justify-content:space-between;gap:10rpx;font-size:23rpx;margin-top:18rpx;color:#85776D}.smoke-period-row text+text{color:#B74768;overflow-wrap:anywhere}.smoke-period-hint{display:block;margin-top:14rpx;font-size:20rpx;color:#A39386;line-height:1.6} + +.smoke-ledger-footer{text-align:center;padding-top:20rpx} diff --git a/weapp/pages/album/album.js b/weapp/pages/album/album.js index 3a3cb98..97c2318 100644 --- a/weapp/pages/album/album.js +++ b/weapp/pages/album/album.js @@ -1,3 +1,4 @@ +const { itemsOf, startList, current, mergeUnique, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination'); const { currentUserLocation } = require('../../utils/user-location'); const { photoForm, metadataRows } = require('../../utils/photo-metadata'); const { withDataCache } = require('../../utils/page-cache'); @@ -5,7 +6,7 @@ const { getLayout } = require('../../utils/layout'); // pages/album/album.js - 日记式分组相册 const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request'); const { requestSubscribeAfterAction } = require('../../utils/subscribe'); -Page(withDataCache({ +Page(withDataCache(withListPagination({ onResize: function() { this.setData(getLayout()); }, data: { loading: true, loadError: false, batches: [], @@ -86,7 +87,8 @@ Page(withDataCache({ return { ...batch, photos, metadata: metadataRows(photos) }; }); const previewImages = this.data.previewImages.map(item => String(item.id) === String(photo.id) ? { ...item, ...form } : item); - this.setData({ batches, previewImages, showPhotoEditor: false }); + this._photoRows = (this._photoRows || []).map(item => String(item.id) === String(photo.id) ? { ...item, ...form } : item); + this.setData({ batches, photoItems: this._photoRows, previewImages, showPhotoEditor: false }); wx.showToast({ title: '照片信息已保存', icon: 'success' }); } catch (error) { wx.showToast({ title: (error && (error.error || error.message)) || '保存失败,请重试', icon: 'none' }); @@ -129,15 +131,14 @@ Page(withDataCache({ }; }, - loadData: async function() { - if (this._pageLoading) return; - this._pageLoading = true; - this.setData({loading: !this._hasCachedData, loadError:false}); + loadData: async function(page) { + const ticket = startList(this,page); + if (!ticket) return; try { var self = this; var albumId = this.data.albumId; if (!albumId) { - var albums = await api.albumList(); + var albums = itemsOf(await api.albumList({page:1,page_size:1})); if (albums && albums.length > 0) { albumId = albums[0].id; this.setData({ albumId: albumId }); @@ -145,11 +146,14 @@ Page(withDataCache({ } if (albumId) { // 共享内存缓存;写操作成功后自动失效。 - var photos = await api.photos(albumId); + var result = await api.photos(albumId, {page:ticket.page,page_size:20}); + if (!current(this,ticket)) return; + var photos = ticket.page === 1 ? itemsOf(result) : mergeUnique(this._photoRows || [], itemsOf(result)); // 根据 batch_id 分组 var batchMap = {}; var myUid = wx.getStorageSync('userId'); var info = await api.coupleInfo(); + if (!current(this,ticket)) return; var p1 = info.partner1 || {}, p2 = info.partner2 || {}; var nameMap = {}; var avatarMap = {}; @@ -182,13 +186,13 @@ Page(withDataCache({ var batches = Object.values(batchMap); batches.sort(function(a, b) { return b.created_at > a.created_at ? 1 : -1; }); batches.forEach(function(batch) { batch.metadata = metadataRows(batch.photos); }); - this.setData({ batches: batches }); - } else { this.setData({batches:[]}); } + this._photoRows = photos; + applyList(this,ticket,result,itemsOf(result),'photoItems',{batches:batches}); + } else { applyList(this,ticket,{items:[],total:0,has_more:false},[],'photoItems',{batches:[]}); } } catch (e) { - this.setData({loadError:true}); console.error(e); } + failList(this,ticket); console.error(e); } finally { - this._pageLoading = false; - this.setData({loading:false}); + finishList(this,ticket); if (this._visible && this._pendingPhotoPicker && !this.data.loadError && this.data.albumId) { this._pendingPhotoPicker = false; this.chooseImage(); @@ -522,4 +526,4 @@ Page(withDataCache({ goToAlbums: function() { wx.navigateBack(); }, goHome: function() { wx.switchTab({ url: '/pages/index/index' }); }, goProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); } -}, {"loadData": ["photos", "albumList"]})); +}, 'loadData'), {"loadData": ["photos", "albumList"]})); diff --git a/weapp/pages/album/album.wxml b/weapp/pages/album/album.wxml index dcb9bdb..ac3180a 100644 --- a/weapp/pages/album/album.wxml +++ b/weapp/pages/album/album.wxml @@ -5,7 +5,7 @@ - + MOMENTS WE KEEP相册里的片段 @@ -36,6 +36,7 @@ +正在加载…加载失败,点击重试加载更多已加载全部照片 diff --git a/weapp/pages/albums/albums.js b/weapp/pages/albums/albums.js index 3d086f6..eb8f097 100644 --- a/weapp/pages/albums/albums.js +++ b/weapp/pages/albums/albums.js @@ -1,8 +1,9 @@ +const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination'); const { withDataCache } = require('../../utils/page-cache'); const { getLayout } = require('../../utils/layout'); // pages/albums/albums.js const { api, getImageUrl } = require('../../utils/request'); -Page(withDataCache({ +Page(withDataCache(withListPagination({ onResize: function() { this.setData(getLayout()); }, data: { loading: true, loadError: false, @@ -47,21 +48,21 @@ Page(withDataCache({ noop: function() {}, - loadAlbums: async function() { - if (this._loading) return; - this._loading = true; - this.setData({ loading: !this._hasCachedData, loadError: false }); + loadAlbums: async function(page) { + const ticket = startList(this, page); + if (!ticket) return; try { - var list = await api.albumList(null, 'GET', true); - list = (list || []).map(function(a) { + var list = await api.albumList({page:ticket.page,page_size:20}); + var result = list; + list = itemsOf(list).map(function(a) { a.cover = getImageUrl(a.cover); a.photo_count = a.photo_count || 0; // 地理位置:优先显示地址名称,其次显示经纬度 a.location_text = a.location || ((a.lat && a.lng) ? (a.lat + ', ' + a.lng) : ''); return a; }); - this.setData({ albums: list }); - } catch(e) { console.error(e); this.setData({ loadError: true }); } finally { this._loading = false; this.setData({ loading: false }); } + applyList(this, ticket, result, list, 'albums'); + } catch(e) { console.error(e); failList(this,ticket); } finally { finishList(this,ticket); } }, openAlbum: function(e) { @@ -174,4 +175,4 @@ Page(withDataCache({ goHome: function() { wx.switchTab({ url: '/pages/index/index' }); }, goBack: function() { wx.navigateBack(); }, goToProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); } - }, {"loadAlbums": ["albumList"]})); + }, 'loadAlbums'), {"loadAlbums": ["albumList"]})); diff --git a/weapp/pages/albums/albums.wxml b/weapp/pages/albums/albums.wxml index 8c04189..ab20807 100644 --- a/weapp/pages/albums/albums.wxml +++ b/weapp/pages/albums/albums.wxml @@ -3,7 +3,7 @@ - + MOMENTS WE KEEP{{uploadMode ? '选择上传照片的相册' : '照片里的日常'}} 还没有照片{{item.title || '我们的相册'}}{{item.photo_count || 0}} 张照片•••{{item.description}} @@ -41,6 +41,7 @@ +正在加载…加载失败,点击重试加载更多已加载全部 diff --git a/weapp/pages/diary/diary.js b/weapp/pages/diary/diary.js index d528b73..2507c86 100644 --- a/weapp/pages/diary/diary.js +++ b/weapp/pages/diary/diary.js @@ -1,10 +1,11 @@ +const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination'); const { withDataCache } = require('../../utils/page-cache'); const { getLayout } = require('../../utils/layout'); // Diary journal with server pagination. const { api, getImageUrl } = require('../../utils/request'); const { diaryEntry } = require('../../utils/journal'); const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos'); -Page(withDataCache({ +Page(withDataCache(withListPagination({ onResize: function() { this.setData(getLayout()); }, data: { loading: true, loadError: false, @@ -51,45 +52,14 @@ Page(withDataCache({ return { title: '让所有的爱有迹可循', imageUrl: '/images/share.png' }; }, - loadData: async function() { - if (this._loading) return; - this._loading = true; - this.setData({ loading: !this._hasCachedData, loadError: false }); + loadData: async function(page) { + const ticket=startList(this,page);if(!ticket)return; try { - var data = await api.diaryList({ page: 1, page_size: this.data.pageSize }); - var items = this._formatItems(data.items || []); - this.setData({ - page: 1, - list: items, - total: data.total || 0, - hasMore: (data.total || 0) > this.data.pageSize - }); + const data=await api.diaryList({page:ticket.page,page_size:20}); + applyList(this,ticket,data,this._formatItems(itemsOf(data)),'list'); this._loadPartnerInfo(); - } catch (e) { - this.setData({ loadError: true }); - console.error(e); - wx.showToast({ title: (e && (e.error || e.errMsg || e.message)) || '加载失败', icon: 'none' }); - } finally { this._loading = false; this.setData({ loading: false }); } - }, - - loadMore: async function() { - if (this.data.loadingMore || !this.data.hasMore) return; - this.setData({ loadingMore: true }); - try { - var nextPage = this.data.page + 1; - var data = await api.diaryList({ page: nextPage, page_size: this.data.pageSize }); - var newItems = this._formatItems(data.items || []); - var merged = this.data.list.concat(newItems); - this.setData({ - list: merged, - page: nextPage, - hasMore: merged.length < (data.total || 0), - loadingMore: false - }); - this._loadPartnerInfo(); - } catch (e) { - this.setData({ loadingMore: false }); - } + } catch(e) { failList(this,ticket); } + finally { finishList(this,ticket); } }, _formatItems: function(items) { @@ -194,4 +164,4 @@ Page(withDataCache({ goHome: function() { this._savePosBeforeLeave(); wx.switchTab({ url: '/pages/index/index' }); }, goBack: function() { wx.navigateBack(); }, goProfile: function() { this._savePosBeforeLeave(); wx.switchTab({ url: '/pages/profile/profile' }); } -}, {"loadData": ["diaryList", "coupleInfo"]})); +}, 'loadData'), {"loadData": ["diaryList", "coupleInfo"]})); diff --git a/weapp/pages/diary/diary.wxml b/weapp/pages/diary/diary.wxml index 72bcb97..10e1514 100644 --- a/weapp/pages/diary/diary.wxml +++ b/weapp/pages/diary/diary.wxml @@ -5,4 +5,5 @@ LIFE IN WORDS生活里的每一页