From bccb81d5984233c58411ec323a3ca975b85f2b5f Mon Sep 17 00:00:00 2001 From: octo-patch Date: Thu, 23 Apr 2026 10:27:55 +0800 Subject: [PATCH] fix: return total count from paginated list APIs for accurate pagination (fixes #2338) The paginated list endpoints for channels, users, tokens, redemptions, and logs previously returned only the current page's data without a total count. The berry theme frontend inferred totals heuristically (data.length + 1 when the page was full), causing TablePagination to only display 2 navigable pages at a time -- users had to click next repeatedly to reach later pages. Changes: - Backend: add Count* functions in model layer (CountChannels, CountUsers, CountUserTokens, CountRedemptions, CountAllLogs, CountUserLogs) that perform a SELECT COUNT(*) with the same filters as the corresponding Get* functions. - Backend: update GetAll* controllers to run the count query and include a total field in the JSON response (backward-compatible; old clients ignore it). - Frontend (berry): store total count in dedicated state, pass it to the TablePagination count prop so all pages are immediately visible. Update onPaginationChange to fetch any not-yet-loaded page on direct navigation. Update search handlers to set count = data.length for search results. --- controller/channel.go | 9 +++++ controller/log.go | 18 +++++++++ controller/redemption.go | 9 +++++ controller/token.go | 11 +++++ controller/user.go | 10 +++++ model/channel.go | 6 +++ model/log.go | 54 +++++++++++++++++++++++++ model/redemption.go | 6 +++ model/token.go | 6 +++ model/user.go | 6 +++ web/berry/src/views/Channel/index.js | 21 ++++++---- web/berry/src/views/Log/index.js | 20 +++++---- web/berry/src/views/Redemption/index.js | 21 ++++++---- web/berry/src/views/Token/index.js | 21 ++++++---- web/berry/src/views/User/index.js | 21 ++++++---- 15 files changed, 204 insertions(+), 35 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index 37bfb99d76..22c0520173 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -23,10 +23,19 @@ func GetAllChannels(c *gin.Context) { }) return } + total, err := model.CountChannels() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": channels, + "total": total, }) return } diff --git a/controller/log.go b/controller/log.go index 665f49be55..d0c671b9b7 100644 --- a/controller/log.go +++ b/controller/log.go @@ -29,10 +29,19 @@ func GetAllLogs(c *gin.Context) { }) return } + total, err := model.CountAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": logs, + "total": total, }) return } @@ -56,10 +65,19 @@ func GetUserLogs(c *gin.Context) { }) return } + total, err := model.CountUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": logs, + "total": total, }) return } diff --git a/controller/redemption.go b/controller/redemption.go index 1d0ffbad89..0fa2040ef6 100644 --- a/controller/redemption.go +++ b/controller/redemption.go @@ -24,10 +24,19 @@ func GetAllRedemptions(c *gin.Context) { }) return } + total, err := model.CountRedemptions() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": redemptions, + "total": total, }) return } diff --git a/controller/token.go b/controller/token.go index 668ccd9788..c50736401a 100644 --- a/controller/token.go +++ b/controller/token.go @@ -30,10 +30,21 @@ func GetAllTokens(c *gin.Context) { }) return } + + total, err := model.CountUserTokens(userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": tokens, + "total": total, }) return } diff --git a/controller/user.go b/controller/user.go index d7fd8d7740..f5e37254a8 100644 --- a/controller/user.go +++ b/controller/user.go @@ -201,10 +201,20 @@ func GetAllUsers(c *gin.Context) { return } + total, err := model.CountUsers() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", "data": users, + "total": total, }) } diff --git a/model/channel.go b/model/channel.go index 4b0f4b01aa..a684152cd1 100644 --- a/model/channel.go +++ b/model/channel.go @@ -66,6 +66,12 @@ func GetAllChannels(startIdx int, num int, scope string) ([]*Channel, error) { return channels, err } +func CountChannels() (int64, error) { + var count int64 + err := DB.Model(&Channel{}).Count(&count).Error + return count, err +} + func SearchChannels(keyword string) (channels []*Channel, err error) { err = DB.Omit("key").Where("id = ? or name LIKE ?", helper.String2Int(keyword), keyword+"%").Find(&channels).Error return channels, err diff --git a/model/log.go b/model/log.go index 2c9206528e..bd575e1412 100644 --- a/model/log.go +++ b/model/log.go @@ -122,6 +122,36 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName return logs, err } +func CountAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int) (int64, error) { + var tx *gorm.DB + if logType == LogTypeUnknown { + tx = LOG_DB.Model(&Log{}) + } else { + tx = LOG_DB.Model(&Log{}).Where("type = ?", logType) + } + if modelName != "" { + tx = tx.Where("model_name = ?", modelName) + } + if username != "" { + tx = tx.Where("username = ?", username) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + if startTimestamp != 0 { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if channel != 0 { + tx = tx.Where("channel_id = ?", channel) + } + var count int64 + err := tx.Count(&count).Error + return count, err +} + func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int) (logs []*Log, err error) { var tx *gorm.DB if logType == LogTypeUnknown { @@ -145,6 +175,30 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int return logs, err } +func CountUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string) (int64, error) { + var tx *gorm.DB + if logType == LogTypeUnknown { + tx = LOG_DB.Model(&Log{}).Where("user_id = ?", userId) + } else { + tx = LOG_DB.Model(&Log{}).Where("user_id = ? and type = ?", userId, logType) + } + if modelName != "" { + tx = tx.Where("model_name = ?", modelName) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + if startTimestamp != 0 { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("created_at <= ?", endTimestamp) + } + var count int64 + err := tx.Count(&count).Error + return count, err +} + func SearchAllLogs(keyword string) (logs []*Log, err error) { err = LOG_DB.Where("type = ? or content LIKE ?", keyword, keyword+"%").Order("id desc").Limit(config.MaxRecentItems).Find(&logs).Error return logs, err diff --git a/model/redemption.go b/model/redemption.go index 957a33be28..0296c97dae 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -36,6 +36,12 @@ func GetAllRedemptions(startIdx int, num int) ([]*Redemption, error) { return redemptions, err } +func CountRedemptions() (int64, error) { + var count int64 + err := DB.Model(&Redemption{}).Count(&count).Error + return count, err +} + func SearchRedemptions(keyword string) (redemptions []*Redemption, err error) { err = DB.Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&redemptions).Error return redemptions, err diff --git a/model/token.go b/model/token.go index 52ee63ef5b..73e9bff513 100644 --- a/model/token.go +++ b/model/token.go @@ -54,6 +54,12 @@ func GetAllUserTokens(userId int, startIdx int, num int, order string) ([]*Token return tokens, err } +func CountUserTokens(userId int) (int64, error) { + var count int64 + err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&count).Error + return count, err +} + func SearchUserTokens(userId int, keyword string) (tokens []*Token, err error) { err = DB.Where("user_id = ?", userId).Where("name LIKE ?", keyword+"%").Find(&tokens).Error return tokens, err diff --git a/model/user.go b/model/user.go index 021810c0f1..da2f13f946 100644 --- a/model/user.go +++ b/model/user.go @@ -77,6 +77,12 @@ func GetAllUsers(startIdx int, num int, order string) (users []*User, err error) return users, err } +func CountUsers() (int64, error) { + var count int64 + err := DB.Model(&User{}).Where("status != ?", UserStatusDeleted).Count(&count).Error + return count, err +} + func SearchUsers(keyword string) (users []*User, err error) { if !common.UsingPostgreSQL { err = DB.Omit("password").Where("id = ? or username LIKE ? or email LIKE ? or display_name LIKE ?", keyword, keyword+"%", keyword+"%", keyword+"%").Find(&users).Error diff --git a/web/berry/src/views/Channel/index.js b/web/berry/src/views/Channel/index.js index 3ca8fdeff9..b4fd30c41b 100644 --- a/web/berry/src/views/Channel/index.js +++ b/web/berry/src/views/Channel/index.js @@ -28,6 +28,7 @@ export default function ChannelPage() { const [activePage, setActivePage] = useState(0); const [searching, setSearching] = useState(false); const [searchKeyword, setSearchKeyword] = useState(''); + const [channelCount, setChannelCount] = useState(0); const theme = useTheme(); const matchUpMd = useMediaQuery(theme.breakpoints.up('sm')); const [openModal, setOpenModal] = useState(false); @@ -36,7 +37,7 @@ export default function ChannelPage() { const loadChannels = async (startIdx) => { setSearching(true); const res = await API.get(`/api/channel/?p=${startIdx}`); - const { success, message, data } = res.data; + const { success, message, data, total } = res.data; if (success) { if (startIdx === 0) { setChannels(data); @@ -45,19 +46,24 @@ export default function ChannelPage() { newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); setChannels(newChannels); } + if (total !== undefined) { + setChannelCount(total); + } } else { showError(message); } setSearching(false); }; - const onPaginationChange = (event, activePage) => { + const onPaginationChange = (event, newPage) => { (async () => { - if (activePage === Math.ceil(channels.length / ITEMS_PER_PAGE)) { - // In this case we have to load more data and then append them. - await loadChannels(activePage); + const pageStart = newPage * ITEMS_PER_PAGE; + const pageEnd = pageStart + ITEMS_PER_PAGE; + if (channels.slice(pageStart, pageEnd).length < ITEMS_PER_PAGE && pageStart < channelCount) { + // Page data not yet loaded, fetch it from the backend. + await loadChannels(newPage); } - setActivePage(activePage); + setActivePage(newPage); })(); }; @@ -73,6 +79,7 @@ export default function ChannelPage() { const { success, message, data } = res.data; if (success) { setChannels(data); + setChannelCount(data.length); setActivePage(0); } else { showError(message); @@ -274,7 +281,7 @@ export default function ChannelPage() { { + const onPaginationChange = (event, newPage) => { (async () => { - if (activePage === Math.ceil(logs.length / ITEMS_PER_PAGE)) { - // In this case we have to load more data and then append them. - await loadLogs(activePage); + const pageStart = newPage * ITEMS_PER_PAGE; + const pageEnd = pageStart + ITEMS_PER_PAGE; + if (logs.slice(pageStart, pageEnd).length < ITEMS_PER_PAGE && pageStart < logCount) { + // Page data not yet loaded, fetch it from the backend. + await loadLogs(newPage); } - setActivePage(activePage); + setActivePage(newPage); })(); }; @@ -146,7 +152,7 @@ export default function Log() { { setSearching(true); const res = await API.get(`/api/redemption/?p=${startIdx}`); - const { success, message, data } = res.data; + const { success, message, data, total } = res.data; if (success) { if (startIdx === 0) { setRedemptions(data); @@ -40,19 +41,24 @@ export default function Redemption() { newRedemptions.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); setRedemptions(newRedemptions); } + if (total !== undefined) { + setRedemptionCount(total); + } } else { showError(message); } setSearching(false); }; - const onPaginationChange = (event, activePage) => { + const onPaginationChange = (event, newPage) => { (async () => { - if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE)) { - // In this case we have to load more data and then append them. - await loadRedemptions(activePage); + const pageStart = newPage * ITEMS_PER_PAGE; + const pageEnd = pageStart + ITEMS_PER_PAGE; + if (redemptions.slice(pageStart, pageEnd).length < ITEMS_PER_PAGE && pageStart < redemptionCount) { + // Page data not yet loaded, fetch it from the backend. + await loadRedemptions(newPage); } - setActivePage(activePage); + setActivePage(newPage); })(); }; @@ -68,6 +74,7 @@ export default function Redemption() { const { success, message, data } = res.data; if (success) { setRedemptions(data); + setRedemptionCount(data.length); setActivePage(0); } else { showError(message); @@ -191,7 +198,7 @@ export default function Redemption() { state.siteInfo); @@ -33,7 +34,7 @@ export default function Token() { const loadTokens = async (startIdx) => { setSearching(true); const res = await API.get(`/api/token/?p=${startIdx}`); - const { success, message, data } = res.data; + const { success, message, data, total } = res.data; if (success) { if (startIdx === 0) { setTokens(data); @@ -42,6 +43,9 @@ export default function Token() { newTokens.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); setTokens(newTokens); } + if (total !== undefined) { + setTokenCount(total); + } } else { showError(message); } @@ -56,13 +60,15 @@ export default function Token() { }); }, []); - const onPaginationChange = (event, activePage) => { + const onPaginationChange = (event, newPage) => { (async () => { - if (activePage === Math.ceil(tokens.length / ITEMS_PER_PAGE)) { - // In this case we have to load more data and then append them. - await loadTokens(activePage); + const pageStart = newPage * ITEMS_PER_PAGE; + const pageEnd = pageStart + ITEMS_PER_PAGE; + if (tokens.slice(pageStart, pageEnd).length < ITEMS_PER_PAGE && pageStart < tokenCount) { + // Page data not yet loaded, fetch it from the backend. + await loadTokens(newPage); } - setActivePage(activePage); + setActivePage(newPage); })(); }; @@ -78,6 +84,7 @@ export default function Token() { const { success, message, data } = res.data; if (success) { setTokens(data); + setTokenCount(data.length); setActivePage(0); } else { showError(message); @@ -202,7 +209,7 @@ export default function Token() { { setSearching(true); const res = await API.get(`/api/user/?p=${startIdx}`); - const { success, message, data } = res.data; + const { success, message, data, total } = res.data; if (success) { if (startIdx === 0) { setUsers(data); @@ -40,19 +41,24 @@ export default function Users() { newUsers.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); setUsers(newUsers); } + if (total !== undefined) { + setUserCount(total); + } } else { showError(message); } setSearching(false); }; - const onPaginationChange = (event, activePage) => { + const onPaginationChange = (event, newPage) => { (async () => { - if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE)) { - // In this case we have to load more data and then append them. - await loadUsers(activePage); + const pageStart = newPage * ITEMS_PER_PAGE; + const pageEnd = pageStart + ITEMS_PER_PAGE; + if (users.slice(pageStart, pageEnd).length < ITEMS_PER_PAGE && pageStart < userCount) { + // Page data not yet loaded, fetch it from the backend. + await loadUsers(newPage); } - setActivePage(activePage); + setActivePage(newPage); })(); }; @@ -68,6 +74,7 @@ export default function Users() { const { success, message, data } = res.data; if (success) { setUsers(data); + setUserCount(data.length); setActivePage(0); } else { showError(message); @@ -193,7 +200,7 @@ export default function Users() {