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生活里的每一页
-正在加载更多…
+正在加载更多…
+
diff --git a/weapp/pages/diary_detail/diary_detail.js b/weapp/pages/diary_detail/diary_detail.js
index 42a3912..281eda6 100644
--- a/weapp/pages/diary_detail/diary_detail.js
+++ b/weapp/pages/diary_detail/diary_detail.js
@@ -1,3 +1,4 @@
+const { itemsOf, totalOf, mergeUnique } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
const { api, getImageUrl } = require('../../utils/request');
@@ -8,7 +9,7 @@ Page(withDataCache({
diary: null,
loading: true,
comments: [],
- commentPage: 1,
+ commentPage: 1, commentsLoadingMore: false, commentsMoreError: false, commentsTotal: 0,
hasMoreComments: true,
showCommentInput: false,
commentText: '',
@@ -78,24 +79,28 @@ Page(withDataCache({
return data;
},
- loadComments: async function() {
- this.setData({ commentsError: false });
+ loadComments: function() { return this._loadCommentsPage(1); },
+ _loadCommentsPage: async function(page) {
+ page = Number(page) || 1;
+ if (this._commentsFirst || (page > 1 && (this.data.commentsLoadingMore || !this.data.hasMoreComments))) return;
+ if (page === 1) { this._commentsFirst = true; this._commentsGeneration = (this._commentsGeneration || 0) + 1; }
+ var generation = this._commentsGeneration;
+ this.setData(page === 1 ? {commentsError:false,commentsMoreError:false,commentsLoadingMore:false} : {commentsLoadingMore:true,commentsMoreError:false});
try {
- var data = await api.diaryCommentList({ diary_id: this.data.diaryId });
- if (data && data.length > 0) {
- data.forEach(function(c) {
- if (c.created_at) {
- c._time = c.created_at.substring(0, 16).replace('T', ' ');
- } else if (c.create_time) {
- c._time = c.create_time.substring(0, 16).replace('T', ' ');
- } else {
- c._time = '';
- }
- });
- }
- this.setData({ comments: data || [] });
- } catch (e) { this.setData({ commentsError: true }); console.error(e); }
+ var result = await api.diaryCommentList({diary_id:this.data.diaryId,page:page,page_size:20});
+ if (generation !== this._commentsGeneration) return;
+ var added = itemsOf(result).map(function(comment) {
+ return Object.assign({}, comment, {_time:String(comment.created_at || comment.create_time || '').slice(0,16).replace('T',' ')});
+ });
+ this.setData({comments:page===1?added:mergeUnique(this.data.comments,added),commentPage:page,commentsTotal:totalOf(result),hasMoreComments:!Array.isArray(result)&&(result.has_more != null?!!result.has_more:page*20
-
+
{{diary.creator_name || 'TA'}}{{diary.created_time}}{{diary.location_text}}
@@ -58,6 +58,8 @@
+
+
diff --git a/weapp/pages/discover/discover.js b/weapp/pages/discover/discover.js
index 21a8917..e604657 100644
--- a/weapp/pages/discover/discover.js
+++ b/weapp/pages/discover/discover.js
@@ -1,3 +1,4 @@
+const { itemsOf, totalOf } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
// pages/discover/discover.js
@@ -46,18 +47,13 @@ Page(withDataCache({
const [timeline, album, diary] = await Promise.all([
api.timelineList(), api.albumList(), api.diaryList()
]);
- var photoCount = 0;
- if (Array.isArray(album)) {
- for (var i = 0; i < album.length; i++) {
- photoCount += parseInt(album[i].photo_count || 0);
- }
- }
+ var photoCount = album && album.photo_total != null ? Number(album.photo_total) : itemsOf(album).reduce((sum,item)=>sum+(Number(item.photo_count)||0),0);
var stats = {
- timeline: Array.isArray(timeline) ? timeline.length : 0,
+ timeline: totalOf(timeline),
album: photoCount,
diary: (diary && diary.total) ? diary.total : 0,
};
- this.setData({ stats: stats, entries: ((diary && diary.items) || []).map(diaryEntry), albums: (Array.isArray(album) ? album : []).map(albumEntry), memories: (Array.isArray(timeline) ? timeline : []).map(memoryEntry) });
+ this.setData({ stats: stats, entries: ((diary && diary.items) || []).map(diaryEntry), albums: itemsOf(album).map(albumEntry), memories: itemsOf(timeline).map(memoryEntry) });
} catch (e) {
console.error(e);
this.setData({loadError:true});
diff --git a/weapp/pages/index/index.js b/weapp/pages/index/index.js
index 9c0a1e4..de8041b 100644
--- a/weapp/pages/index/index.js
+++ b/weapp/pages/index/index.js
@@ -1,3 +1,4 @@
+const { itemsOf, totalOf } = require('../../utils/list-pagination');
let lastLocationReport = 0;
let locationReportUser = null;
const { withDataCache } = require('../../utils/page-cache');
@@ -221,29 +222,24 @@ Page(withDataCache({
api.songsList(), api.checkinList(),
this.data.coupleId ? api.petInfo() : Promise.resolve({ has_pet: false })
]);
- var photoCount = 0;
- if (Array.isArray(album)) {
- for (var i = 0; i < album.length; i++) {
- photoCount += parseInt(album[i].photo_count || 0);
- }
- }
+ var photoCount = album && album.photo_total != null ? Number(album.photo_total) : itemsOf(album).reduce((sum,item)=>sum+(Number(item.photo_count)||0),0);
var petLevel = 0;
if (pet && pet.has_pet && pet.pet) {
petLevel = parseInt(pet.pet.level || 0);
}
var recentDiary = ((diary && diary.items) || []).slice(0, 1).map(diaryEntry);
var today = new Date(); today.setHours(0,0,0,0);
- var future = (Array.isArray(timeline) ? timeline : []).filter(function(item) { return item.event_date && new Date(item.event_date.replace(/-/g, '/')) >= today; }).sort(function(a,b) { return a.event_date.localeCompare(b.event_date); });
- var next = future[0];
+ var future = itemsOf(timeline).filter(function(item) { return item.event_date && new Date(item.event_date.replace(/-/g, '/')) >= today; }).sort(function(a,b) { return a.event_date.localeCompare(b.event_date); });
+ var next = timeline && !Array.isArray(timeline) && 'upcoming' in timeline ? timeline.upcoming : future[0];
this.setData({
recentDiary: recentDiary,
- recentAlbums: (Array.isArray(album) ? album : []).filter(function(item) { return !!item.cover; }).slice(0,3).map(function(item) { return { id:item.id, title:item.title || '我们的相册', cover:getImageUrl(item.cover) }; }),
+ recentAlbums: itemsOf(album).filter(function(item) { return !!item.cover; }).slice(0,3).map(function(item) { return { id:item.id, title:item.title || '我们的相册', cover:getImageUrl(item.cover) }; }),
upcomingMemory: next ? { title: next.title || '下一个记忆日', date: next.event_date, dateMonth: next.event_date.slice(5,7), dateDay: next.event_date.slice(8,10), days: Math.ceil((new Date(next.event_date.replace(/-/g, '/')) - today) / 86400000) } : null,
stats: {
- timeline: Array.isArray(timeline) ? timeline.length : 0,
+ timeline: totalOf(timeline),
album: photoCount,
diary: (diary && diary.total) ? diary.total : 0,
- songs: Array.isArray(songs) ? songs.length : 0,
+ songs: totalOf(songs),
checkin: (checkin && checkin.checkin_log_total !== undefined) ? checkin.checkin_log_total : ((checkin && checkin.items) ? checkin.items.length : (Array.isArray(checkin) ? checkin.length : 0)),
pet: petLevel
}
diff --git a/weapp/pages/lovewords/lovewords.js b/weapp/pages/lovewords/lovewords.js
index 4b9e2fb..fa696cf 100644
--- a/weapp/pages/lovewords/lovewords.js
+++ b/weapp/pages/lovewords/lovewords.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/lovewords/lovewords.js - 一起听过的歌
const { api } = require('../../utils/request');
-Page(withDataCache({
+Page(withDataCache(withListPagination({
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
list: [],
@@ -30,13 +31,12 @@ Page(withDataCache({
return { title: '让所有的爱有迹可循', imageUrl: '/images/share.png' };
},
- 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 {
- const list = await api.songsList();
- const items = (list || []).map(function(item) {
+ const list = await api.songsList({page:ticket.page,page_size:20});
+ const items = itemsOf(list).map(function(item) {
if (item.created_at) {
var d = new Date(item.created_at);
item.date_month = String(d.getMonth() + 1).padStart(2, '0');
@@ -44,13 +44,13 @@ Page(withDataCache({
}
return item;
});
- this.setData({ list: items });
+ applyList(this, ticket, list, items, 'list');
} catch (e) {
- this.setData({loadError:true});
+ failList(this,ticket);
console.error(e);
wx.showToast({ title: '加载失败', icon: 'none' });
}
- finally { this._pageLoading = false; this.setData({loading:false}); }
+ finally { finishList(this,ticket); }
},
noop: function() {},
@@ -124,4 +124,4 @@ Page(withDataCache({
goBack: function() { wx.navigateBack(); },
goHome: function() { wx.switchTab({ url: '/pages/index/index' }); },
goProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); }
-}, {"loadData": ["songsList"]}));
+}, 'loadData'), {"loadData": ["songsList"]}));
diff --git a/weapp/pages/lovewords/lovewords.wxml b/weapp/pages/lovewords/lovewords.wxml
index 83fbfa8..59a3835 100644
--- a/weapp/pages/lovewords/lovewords.wxml
+++ b/weapp/pages/lovewords/lovewords.wxml
@@ -5,7 +5,7 @@
-
+
OUR SOUNDTRACK一起听过的旋律
@@ -21,6 +21,7 @@
+
diff --git a/weapp/pages/profile/profile.js b/weapp/pages/profile/profile.js
index f0c96cc..b607f2c 100644
--- a/weapp/pages/profile/profile.js
+++ b/weapp/pages/profile/profile.js
@@ -1,3 +1,4 @@
+const { itemsOf, totalOf } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
// pages/profile/profile.js - 与原网站一致的个人中心
@@ -1025,11 +1026,11 @@ Page(withDataCache({
const key=keys[index];
if(result.status !== 'fulfilled') { counts[key]='—';failed=true;return; }
const value=result.value;
- if(key==='album') counts[key]=(Array.isArray(value)?value:[]).reduce((sum,album)=>sum+(Number(album.photo_count)||0),0);
+ if(key==='album') counts[key]=value && value.photo_total != null ? Number(value.photo_total) : itemsOf(value).reduce((sum,album)=>sum+(Number(album.photo_count)||0),0);
else if(key==='diary') counts[key]=Number(value && value.total)||0;
else if(key==='checkin') counts[key]=value && value.checkin_log_total !== undefined ? Number(value.checkin_log_total)||0 : (Array.isArray(value)?value.length:(value && value.items || []).length);
else if(key==='pet') counts[key]=value && value.has_pet && value.pet ? 'Lv.'+(Number(value.pet.level)||0) : '未领养';
- else counts[key]=Array.isArray(value)?value.length:0;
+ else counts[key]=totalOf(value);
});
this.setData({statisticItems:this.data.statisticItems.map(item=>({...item,count:counts[item.key]===undefined?item.count:counts[item.key]})),statsLoading:false,statsError:failed});
},
diff --git a/weapp/pages/punch/punch.js b/weapp/pages/punch/punch.js
index e87747a..13751c2 100644
--- a/weapp/pages/punch/punch.js
+++ b/weapp/pages/punch/punch.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/punch/punch.js - 情侣打卡
const { api, getImageUrl } = require('../../utils/request');
-Page(withDataCache({
+Page(withDataCache(withListPagination({
onResize: function() { this.setData(getLayout()); },
data: { loadError: false,
checkins: [],
@@ -54,16 +55,15 @@ 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 {
const [res, stats] = await Promise.all([
- api.checkinList(),
+ api.checkinList({page:ticket.page,page_size:20}),
api.checkinStats().catch(() => ({}))
]);
- const list = (res.items || res || []).map(function(item) {
+ const list = itemsOf(res).map(function(item) {
item.type = item.type || 'both';
var typeLabels = { both: '双方', first: '第一视角', her: '她打卡' };
item.type_label = typeLabels[item.type] || '双方';
@@ -89,10 +89,11 @@ Page(withDataCache({
todayPartner: stats.today_partner_count || 0,
loading: false
};
- this.setData(data);
+ delete data.checkins;
+ applyList(this, ticket, res, list, 'checkins', data);
} catch(e) {
- this.setData({loadError:true}); console.error(e); this.setData({ loading: false }); }
- finally { this._pageLoading = false; this.setData({loading:false}); }
+ failList(this,ticket); console.error(e); this.setData({ loading: false }); }
+ finally { finishList(this,ticket); }
},
// ===== 点击卡片 → 详情弹窗 =====
@@ -119,4 +120,4 @@ Page(withDataCache({
goBack: function() { wx.navigateBack(); },
goHome: function() { wx.switchTab({ url: '/pages/index/index' }); },
goProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); }
-}, {"loadData": ["checkinList", "checkinStats", "coupleInfo"]}));
+}, 'loadData'), {"loadData": ["checkinList", "checkinStats", "coupleInfo"]}));
diff --git a/weapp/pages/punch/punch.wxml b/weapp/pages/punch/punch.wxml
index dc85db4..1127406 100644
--- a/weapp/pages/punch/punch.wxml
+++ b/weapp/pages/punch/punch.wxml
@@ -3,7 +3,7 @@
-
+
LITTLE PROMISES把约定变成习惯
@@ -83,6 +83,7 @@
+
diff --git a/weapp/pages/smoking-detail/smoking-detail.js b/weapp/pages/smoking-detail/smoking-detail.js
index 2b7014a..6debf64 100644
--- a/weapp/pages/smoking-detail/smoking-detail.js
+++ b/weapp/pages/smoking-detail/smoking-detail.js
@@ -3,5 +3,6 @@ Page({
data:{},
onLoad(){this.setData(getLayout());},
onResize(){this.setData(getLayout());},
+ loadMore(){const card=this.selectComponent('#smoking');if(card)return card.loadMoreLogs();},
async onPullDownRefresh(){try{const card=this.selectComponent('#smoking');if(card)await card.load(true);}finally{wx.stopPullDownRefresh();}}
});
diff --git a/weapp/pages/smoking-detail/smoking-detail.wxml b/weapp/pages/smoking-detail/smoking-detail.wxml
index a9514e3..2440d13 100644
--- a/weapp/pages/smoking-detail/smoking-detail.wxml
+++ b/weapp/pages/smoking-detail/smoking-detail.wxml
@@ -1 +1 @@
-
+
diff --git a/weapp/pages/timeline/timeline.js b/weapp/pages/timeline/timeline.js
index fef939a..77fc505 100644
--- a/weapp/pages/timeline/timeline.js
+++ b/weapp/pages/timeline/timeline.js
@@ -1,3 +1,4 @@
+const { itemsOf, startList, applyList, failList, finishList, withListPagination } = require('../../utils/list-pagination');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout');
// pages/timeline/timeline.js
@@ -56,7 +57,7 @@ function calcNearestRepeatDate(eventDate, today, interval, unit) {
return new Date(eventDate);
}
-Page(withDataCache({
+Page(withDataCache(withListPagination({
onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false,
list: []
@@ -82,12 +83,11 @@ Page(withDataCache({
return { title: '让所有的爱有迹可循', imageUrl: '/images/share.png' };
},
- 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 {
- const list = await api.timelineList();
+ const list = await api.timelineList({page:ticket.page,page_size:20});
const myUid = wx.getStorageSync('userId');
var nameMap = {};
var avatarMap = {};
@@ -100,12 +100,12 @@ Page(withDataCache({
} catch (e) {}
var today = new Date(); today.setHours(0,0,0,0);
- var items = (list || []).map(function(item, idx) {
+ var items = itemsOf(list).map(function(item, idx) {
var cid = parseInt(item.created_by) || 0;
item.creator_name = nameMap[cid] || (cid == myUid ? '我' : 'TA');
item.creator_avatar = avatarMap[cid] || '';
item.creator_initial = (item.creator_name || '?')[0].toUpperCase();
- item.side = idx % 2 === 0 ? 'left' : 'right';
+ item.side = ((ticket.page-1)*20+idx) % 2 === 0 ? 'left' : 'right';
item.bg_image_url = item.bg_image ? getImageUrl(item.bg_image) : '';
// 计算纪念日/倒数日(支持重复事件)
@@ -150,13 +150,13 @@ Page(withDataCache({
}
return item;
});
- this.setData({ list: items });
+ applyList(this, ticket, list, items, 'list');
} catch (e) {
- this.setData({loadError:true});
+ failList(this,ticket);
console.error(e);
wx.showToast({ title: (e && (e.error || e.errMsg || e.message)) || '加载失败', icon: 'none' });
}
- finally { this._pageLoading = false; this.setData({loading:false}); }
+ finally { finishList(this,ticket); }
},
@@ -192,4 +192,4 @@ Page(withDataCache({
goHome: function() { wx.switchTab({ url: '/pages/index/index' }); },
goBack: function() { wx.navigateBack(); },
goProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); }
-}, {"loadData": ["timelineList", "coupleInfo"]}));
+}, 'loadData'), {"loadData": ["timelineList", "coupleInfo"]}));
diff --git a/weapp/pages/timeline/timeline.wxml b/weapp/pages/timeline/timeline.wxml
index 74a283e..4d51e5e 100644
--- a/weapp/pages/timeline/timeline.wxml
+++ b/weapp/pages/timeline/timeline.wxml
@@ -6,7 +6,7 @@
-
+
MEMORIES OF US值得记住的日子
@@ -48,6 +48,7 @@
+
diff --git a/weapp/styles/journal.wxss b/weapp/styles/journal.wxss
index 6927fd0..761c80f 100644
--- a/weapp/styles/journal.wxss
+++ b/weapp/styles/journal.wxss
@@ -119,3 +119,5 @@ scroll-view.j-main-scroll { position:fixed; top:var(--nav-height,64px); left:0;
}
.j-handwritten-page .j-link { font-size:26rpx; }
.j-handwritten-page .j-profile-setting .j-link { font-size:32rpx; }
+
+.j-pagination { padding:28rpx 0 40rpx; text-align:center; color:#85776D; font-size:23rpx; }
diff --git a/weapp/tests/journal.test.cjs b/weapp/tests/journal.test.cjs
index 5cc1a18..7c69438 100644
--- a/weapp/tests/journal.test.cjs
+++ b/weapp/tests/journal.test.cjs
@@ -15,6 +15,7 @@ function records(api) {
Page:p=>definition=p, console:{error(){}}, setTimeout:f=>f(),
wx:{getStorageSync:()=> 'token',navigateTo:p=>routes.push(p.url)},
require(name) {
+ if(name.endsWith('/list-pagination'))return require('../utils/list-pagination');
if(name.endsWith('/page-cache'))return {withDataCache:p=>p};
if(name.endsWith('/request'))return {api};
if(name.endsWith('/journal'))return formatter();
diff --git a/weapp/tests/list-pagination.test.cjs b/weapp/tests/list-pagination.test.cjs
new file mode 100644
index 0000000..227608d
--- /dev/null
+++ b/weapp/tests/list-pagination.test.cjs
@@ -0,0 +1,39 @@
+const test=require('node:test');
+const assert=require('node:assert/strict');
+const path=require('node:path');
+const helpers=require('../utils/list-pagination');
+const envelope=(items,total=42,has_more=true)=>({items,total,has_more});
+function page(file,api){
+ let definition;
+ const load=require('./load-module.cjs')({Page:p=>definition=p,wx:{getStorageSync:()=>1,showToast(){}},console,Date,Promise,setTimeout},{'../../utils/request':{api,getImageUrl:v=>v},'../../utils/page-cache':{withDataCache:p=>p}});
+ load(path.join(__dirname,'../pages',file));
+ return {...definition,data:{...definition.data},setData(values){Object.assign(this.data,values);}};
+}
+test('songs append once, preserve full total, and retry the failed page',async()=>{
+ let fail=true;const calls=[];
+ const p=page('lovewords/lovewords.js',{songsList:async({page,page_size})=>{calls.push(page);assert.equal(page_size,20);if(page===1)return envelope([{id:1}]);if(fail)throw Error('offline');return envelope([{id:1},{id:2}],42,false);}});
+ await p.loadData();assert.equal(p.data.total,42);
+ const originalError=console.error;console.error=()=>{};
+ try {await p.loadMore();}finally{console.error=originalError;}
+ assert.equal(p.data.page,1);assert.equal(p.data.list.length,1);assert.equal(p.data.moreError,true);
+ fail=false;await p.loadMore();assert.deepEqual(calls,[1,2,2]);assert.equal(p.data.list.length,2);assert.equal(p.data.hasMore,false);
+ await p.loadMore();assert.equal(calls.length,3);
+});
+test('refresh invalidates an in-flight next page',()=>{
+ const p={data:{list:[]},setData(v){Object.assign(this.data,v);}};
+ let t=helpers.startList(p,1);helpers.applyList(p,t,envelope([{id:1}]),[{id:1}],'list');helpers.finishList(p,t);
+ const old=helpers.startList(p,2);t=helpers.startList(p,1);
+ helpers.applyList(p,t,envelope([{id:9}]),[{id:9}],'list');helpers.finishList(p,t);
+ helpers.applyList(p,old,envelope([{id:2}]),[{id:2}],'list');helpers.finishList(p,old);
+ assert.deepEqual(p.data.list,[{id:9}]);assert.equal(p.data.page,1);
+});
+test('album list appends the next page instead of loading all albums',async()=>{
+ const calls=[];const p=page('albums/albums.js',{albumList:async({page,page_size})=>{calls.push([page,page_size]);return envelope([{id:page,title:'album'}],21,page===1);}});
+ await p.loadAlbums();await p.loadMore();assert.deepEqual(calls,[[1,20],[2,20]]);assert.equal(p.data.albums.length,2);assert.equal(p.data.total,21);
+});
+test('photos crossing page boundaries stay in the same upload card and retain saved metadata',async()=>{
+ const calls=[];const p=page('album/album.js',{photos:async(id,{page,page_size})=>{calls.push([id,page,page_size]);return envelope([{id:page,batch_id:'same',created_by:1,path:page+'.jpg',created_at:'2026-10-11',location:page===1?'old':'second'}],2,page===1);},coupleInfo:async()=>({partner1:{id:1,nickname:'我'},partner2:{id:2,nickname:'TA'}}),photoUpdate:async()=>({success:true})});
+ p.data.albumId=8;await p.loadData();
+ p.data.editPhotos=p.data.batches[0].photos;p.data.editForm={taken_at:'',location:'saved',activity:''};await p.savePhotoInfo();
+ await p.loadMore();assert.deepEqual(calls,[[8,1,20],[8,2,20]]);assert.equal(p.data.batches.length,1);assert.equal(p.data.batches[0].photos.length,2);assert.equal(p.data.batches[0].photos[0].location,'saved');assert.equal(p.data.hasMore,false);
+});
diff --git a/weapp/tests/request.test.cjs b/weapp/tests/request.test.cjs
index b7dc41c..a9f71ee 100644
--- a/weapp/tests/request.test.cjs
+++ b/weapp/tests/request.test.cjs
@@ -38,7 +38,7 @@ test('failed homepage load ends the recent-record spinner',async()=>{
let definition;
const api={coupleInfo:async()=>{throw { _statusCode:404 };}};
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../pages/index/index.js'),'utf8'),{
- Page(p){definition=p;},require(name){return name.endsWith('/page-cache')?{withDataCache:p=>p}:name.endsWith('/request')?{api,getImageUrl:v=>v}:name.endsWith('/layout')?{getLayout:()=>({})}:{saveScrollPosition(){},getScrollPosition(){return 0;}};},
+ Page(p){definition=p;},require(name){return name.endsWith('/list-pagination')?require('../utils/list-pagination'):name.endsWith('/page-cache')?{withDataCache:p=>p}:name.endsWith('/request')?{api,getImageUrl:v=>v}:name.endsWith('/layout')?{getLayout:()=>({})}:{saveScrollPosition(){},getScrollPosition(){return 0;}};},
wx:{getStorageSync:()=> 'test-token'}, console:{error(){}}, Date, setTimeout
});
const page={...definition,data:{...definition.data},setData(data){Object.assign(this.data,data);}};
@@ -50,7 +50,7 @@ test('unbound newcomer loads homepage records without requesting couple-only pet
let definition;let petCalls=0;
const api={coupleInfo:async()=>({not_bound:true,partner1:{nickname:'新用户'}}),timelineList:async()=>[],albumList:async()=>[],diaryList:async()=>({items:[],total:0}),songsList:async()=>[],checkinList:async()=>({items:[]}),petInfo:async()=>{petCalls++;throw Error('请先绑定情侣');}};
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../pages/index/index.js'),'utf8'),{
- Page(p){definition=p;},require(name){return name.endsWith('/page-cache')?{withDataCache:p=>p}:name.endsWith('/request')?{api,getImageUrl:v=>v}:name.endsWith('/layout')?{getLayout:()=>({})}:name.endsWith('/journal')?{diaryEntry:v=>v}:{};},
+ Page(p){definition=p;},require(name){return name.endsWith('/list-pagination')?require('../utils/list-pagination'):name.endsWith('/page-cache')?{withDataCache:p=>p}:name.endsWith('/request')?{api,getImageUrl:v=>v}:name.endsWith('/layout')?{getLayout:()=>({})}:name.endsWith('/journal')?{diaryEntry:v=>v}:{};},
wx:{getStorageSync:()=>1,removeStorageSync(){}},console,Date,setTimeout
});
const page={...definition,data:{...definition.data,coupleId:99},setData(data){Object.assign(this.data,data);}};
diff --git a/weapp/utils/list-pagination.js b/weapp/utils/list-pagination.js
new file mode 100644
index 0000000..f961b86
--- /dev/null
+++ b/weapp/utils/list-pagination.js
@@ -0,0 +1,29 @@
+function itemsOf(result) { return Array.isArray(result) ? result : (result && result.items) || []; }
+function totalOf(result) { return result && result.total != null ? Number(result.total) : itemsOf(result).length; }
+function startList(page, number) {
+ number = Number(number) || 1;
+ if (page._paginationFirst || (number > 1 && (page.data.loadingMore || page.data.loading || page.data.loadError || !page.data.hasMore))) return null;
+ if (number === 1) { page._paginationFirst = true; page._paginationGeneration = (page._paginationGeneration || 0) + 1; }
+ const ticket = { page: number, generation: page._paginationGeneration };
+ page.setData(number === 1 ? {loading: !page._hasCachedData, loadError:false, loadingMore:false, moreError:false} : {loadingMore:true, moreError:false});
+ return ticket;
+}
+function current(page, ticket) { return ticket.generation === page._paginationGeneration; }
+function mergeUnique(previous, added) {
+ const seen=new Set();return previous.concat(added).filter(item=>{const id=String(item.id);if(seen.has(id))return false;seen.add(id);return true;});
+}
+function applyList(page, ticket, result, items, field, extra) {
+ if (!current(page,ticket)) return;
+ const list=ticket.page===1?items:mergeUnique(page.data[field]||[],items);
+ const total=totalOf(result);
+ page.setData(Object.assign({[field]:list,page:ticket.page,total,hasMore:!Array.isArray(result)&&(result.has_more != null ? !!result.has_more : ticket.page*20 request('updatePartnerProfile', data),
// 时间线
- timelineList: () => request('timelineList', null, 'GET'),
+ timelineList: (data = {}) => request('timelineList', { page: 1, page_size: 20, ...data }, 'GET'),
timelineAdd: (data) => request('timelineAdd', data),
timelineUpdate: (data) => request('timelineUpdate', data),
timelineDelete: (data) => request('timelineDelete', data),
timelineDetail: (id) => request('timelineDetail', { id: id }, 'GET'),
// 相册
- albumList: (data, method) => request('albumList', data, method || 'GET'),
+ albumList: (data, method) => request('albumList', { page: 1, page_size: 20, ...data }, method || 'GET'),
albumAdd: (data) => request('albumAdd', data),
albumUpdate: (data) => request('albumUpdate', data),
albumDelete: (data) => request('albumDelete', data),
photoDelete: (data) => request('photoDelete', data),
photoAdd: (data) => request('photoAdd', data),
photoUpdate: (data) => request('photoUpdate', data),
- photos: (albumId, data, method) => request('photos', { album_id: albumId, ...data }, method || 'GET'),
+ photos: (albumId, data, method) => request('photos', { page: 1, page_size: 20, album_id: albumId, ...data }, method || 'GET'),
photoUpload: async (filePath, albumId) => {
wx.showLoading({ title: '上传中...' });
try {
@@ -351,21 +351,21 @@ const api = {
diaryUpdate: (data) => request('diaryUpdate', data),
diaryDelete: (data) => request('diaryDelete', data),
diaryDetail: (id) => request('diaryDetail', { id: id }, 'GET'),
- diaryCommentList: (data) => request('diaryCommentList', data, 'GET'),
+ diaryCommentList: (data) => request('diaryCommentList', { page: 1, page_size: 20, ...data }, 'GET'),
diaryCommentAdd: (data) => request('diaryCommentAdd', data),
diaryCommentDelete: (data) => request('diaryCommentDelete', data),
// 点赞/收藏功能已移除
// 一起听过的歌
- songsList: () => request('songsList', null, 'GET'),
+ songsList: (data = {}) => request('songsList', { page: 1, page_size: 20, ...data }, 'GET'),
songsAdd: (data) => request('songsAdd', data),
songsUpdate: (data) => request('songsUpdate', data),
songsDelete: (data) => request('songsDelete', data),
// 打卡
- smokingInfo: (userId) => request("smokingInfo", { user_id: userId || 0 }, "GET"),
+ smokingInfo: (userId, data = {}) => request("smokingInfo", { page: 1, page_size: 20, ...data, user_id: userId || 0 }, "GET"),
smokingAction: (data) => request("smokingAction", data),
- checkinList: () => request('checkinList', null, 'GET'),
+ checkinList: (data = {}) => request('checkinList', { page: 1, page_size: 20, ...data }, 'GET'),
checkinAdd: (data) => request('checkinAdd', data),
checkinDo: (data) => request('checkinDo', data),
checkinStats: () => request('checkinStats', null, 'GET'),