diff --git a/frontend/src/api/category.ts b/frontend/src/api/category.ts
index 3c57c44..3909b3f 100644
--- a/frontend/src/api/category.ts
+++ b/frontend/src/api/category.ts
@@ -6,6 +6,11 @@ export function getCategoryList(type?: string) {
return request.get('/category/list', { params: type ? { type } : {} })
}
+/** 分页获取分类列表,可按类型筛选 */
+export function getCategoryPage(params: { type?: string; page?: number; size?: number }) {
+ return request.get('/category/page', { params })
+}
+
/** 创建新分类 */
export function createCategory(data: { name: string; type: string; icon?: string; sortOrder?: number }) {
return request.post('/category', data)
diff --git a/frontend/src/api/channel.ts b/frontend/src/api/channel.ts
index 9b510c6..9ad6277 100644
--- a/frontend/src/api/channel.ts
+++ b/frontend/src/api/channel.ts
@@ -14,6 +14,11 @@ export function getChannelList() {
return request.get('/channel')
}
+/** 分页查询当前用户的自定义渠道列表(含使用账单数) */
+export function getChannelPage(params: { page?: number; size?: number }) {
+ return request.get('/channel/page', { params })
+}
+
/** 新建自定义渠道 */
export function createChannel(name: string) {
return request.post('/channel', { name })
diff --git a/frontend/src/api/emailBill.ts b/frontend/src/api/emailBill.ts
index 0a39877..ac75042 100644
--- a/frontend/src/api/emailBill.ts
+++ b/frontend/src/api/emailBill.ts
@@ -99,6 +99,11 @@ export function listEmailBillConfigs() {
return request.get('/email-bill/configs')
}
+/** 分页查询邮箱配置列表 */
+export function pageEmailBillConfigs(params: { page?: number; size?: number }) {
+ return request.get('/email-bill/configs/page', { params })
+}
+
/** 新增邮箱配置 */
export function createEmailBillConfig(data: EmailBillConfigPayload) {
return request.post('/email-bill/configs', data)
@@ -134,6 +139,11 @@ export function listEmailBillSenderRules() {
return request.get('/email-bill/sender-rules')
}
+/** 分页查询发件人规则列表 */
+export function pageEmailBillSenderRules(params: { page?: number; size?: number }) {
+ return request.get('/email-bill/sender-rules/page', { params })
+}
+
/** 新增发件人规则 */
export function createEmailBillSenderRule(data: EmailBillSenderRulePayload) {
return request.post('/email-bill/sender-rules', data)
diff --git a/frontend/src/api/setting.ts b/frontend/src/api/setting.ts
index 2fb308a..a47d50c 100644
--- a/frontend/src/api/setting.ts
+++ b/frontend/src/api/setting.ts
@@ -30,3 +30,13 @@ export function getShowHiddenData() {
export function updateShowHiddenData(show: boolean) {
return request.put('/setting/show-hidden-data', { show })
}
+
+/** 查询当前用户配置的表格分页偏好(每页条数候选列表与默认每页条数) */
+export function getPaginationPrefs() {
+ return request.get('/setting/pagination-prefs')
+}
+
+/** 更新当前用户配置的表格分页偏好 */
+export function updatePaginationPrefs(data: { options: number[]; defaultSize: number }) {
+ return request.put('/setting/pagination-prefs', data)
+}
diff --git a/frontend/src/api/tag.ts b/frontend/src/api/tag.ts
index 92a0ec1..8647bcc 100644
--- a/frontend/src/api/tag.ts
+++ b/frontend/src/api/tag.ts
@@ -6,6 +6,11 @@ export function getTagList() {
return request.get('/tag/list')
}
+/** 分页获取标签列表 */
+export function getTagPage(params: { page?: number; size?: number }) {
+ return request.get('/tag/page', { params })
+}
+
/** 创建新标签 */
export function createTag(data: { name: string; color?: string; sortOrder?: number }) {
return request.post('/tag', data)
diff --git a/frontend/src/components/DisplayConfigPanel.vue b/frontend/src/components/DisplayConfigPanel.vue
index 48d7f3f..ea0ebce 100644
--- a/frontend/src/components/DisplayConfigPanel.vue
+++ b/frontend/src/components/DisplayConfigPanel.vue
@@ -1,12 +1,16 @@
@@ -147,6 +214,21 @@ onMounted(() => {
+
+
{{ t('display.pageSizeLabel') }}
+
{{ t('display.pageSizeHint') }}
+
+
+
+
+ {{ t('display.defaultPageSizeLabel') }}
+
+
+ {{ t('display.pageSizeSave') }}
+
+
+
+
diff --git a/frontend/src/components/TransactionRelationEditorModal.vue b/frontend/src/components/TransactionRelationEditorModal.vue
index a4f83cf..ee4d698 100644
--- a/frontend/src/components/TransactionRelationEditorModal.vue
+++ b/frontend/src/components/TransactionRelationEditorModal.vue
@@ -11,6 +11,8 @@ import { getAiConfig, parseRelationByAi } from '../api/ai'
import { getChannelLabel } from '../utils/channel'
import { readString, writeString } from '../utils/storage'
import { STORAGE_KEYS } from '../utils/storageKeys'
+import { usePageSizeOptions } from '../composables/usePageSizeOptions'
+import { useTablePageSize } from '../composables/useTablePageSize'
/** 关系类型偏好存储键(界面偏好统一以 finance. 前缀命名) */
const RELATION_TYPE_KEY = STORAGE_KEYS.transactionRelationType
@@ -34,6 +36,9 @@ const emit = defineEmits<{
const { t } = useI18n()
const message = useMessage()
+// 所有表格共用的每页条数候选列表
+const { pageSizeOptions } = usePageSizeOptions()
+
// 编辑状态
const editingGroupId = ref(null)
const editorType = ref('PARENT_CHILD')
@@ -52,7 +57,7 @@ const showPicker = ref(false)
const pickerRows = ref([])
const pickerChecked = ref([])
const pickerPage = ref(1)
-const pickerSize = ref(10)
+const pickerSize = useTablePageSize('relationPicker')
const pickerTotal = ref(0)
const pickerRemark = ref('')
const pickerLoading = ref(false)
@@ -287,6 +292,12 @@ async function loadPicker(page: number) {
}
}
+/** 候选账单每页条数变化,回到第一页重新加载 */
+function handlePickerPageSizeChange(size: number) {
+ pickerSize.value = size
+ loadPicker(1)
+}
+
/** 确认选择候选账单 */
function confirmPick() {
const existing = new Set(editorMembers.value.map((item) => item.transactionId))
@@ -423,9 +434,12 @@ const pickerColumns = computed>(() => [
{{ t('common.cancel') }}
diff --git a/frontend/src/composables/usePageSizeOptions.ts b/frontend/src/composables/usePageSizeOptions.ts
new file mode 100644
index 0000000..0aef8f3
--- /dev/null
+++ b/frontend/src/composables/usePageSizeOptions.ts
@@ -0,0 +1,59 @@
+// 表格分页偏好:所有表格共用的每页条数候选列表与默认每页条数,统一从后端用户设置读取,未配置时回退默认值
+import { ref } from 'vue'
+import { getPaginationPrefs } from '../api/setting'
+
+/** 默认每页条数候选列表 */
+export const DEFAULT_PAGE_SIZE_OPTIONS: number[] = [10, 20, 50, 100]
+
+/** 默认每页条数 */
+export const DEFAULT_PAGE_SIZE = 20
+
+// 模块级单例,供所有表格响应式共享
+const pageSizeOptions = ref([...DEFAULT_PAGE_SIZE_OPTIONS])
+const defaultPageSize = ref(DEFAULT_PAGE_SIZE)
+let loaded = false
+
+/** 将后端返回值规范化为升序去重的正整数数组,非法时回退默认 */
+function normalizeOptions(data: unknown): number[] {
+ if (!Array.isArray(data)) return [...DEFAULT_PAGE_SIZE_OPTIONS]
+ const list = data.map((v) => Number(v)).filter((v) => Number.isInteger(v) && v > 0)
+ return list.length ? Array.from(new Set(list)).sort((a, b) => a - b) : [...DEFAULT_PAGE_SIZE_OPTIONS]
+}
+
+/** 规范化默认每页条数:须为候选列表内的正整数,否则回退候选列表首项 */
+function normalizeDefault(value: unknown, options: number[]): number {
+ const size = Number(value)
+ return Number.isInteger(size) && options.includes(size) ? size : options[0]
+}
+
+/**
+ * 从后端加载分页偏好;force 为 true 时强制刷新(设置页保存后调用)。
+ *
+ * :param force: 是否强制重新加载
+ * :returns: 当前的候选列表
+ */
+export async function loadPageSizeOptions(force = false): Promise {
+ if (loaded && !force) return pageSizeOptions.value
+ try {
+ const res: any = await getPaginationPrefs()
+ const options = normalizeOptions(res?.data?.options)
+ pageSizeOptions.value = options
+ defaultPageSize.value = normalizeDefault(res?.data?.defaultSize, options)
+ loaded = true
+ } catch (e) {
+ // 加载失败时保持默认值
+ }
+ return pageSizeOptions.value
+}
+
+/**
+ * 获取共享的分页偏好(候选列表与默认每页条数),首次调用时异步加载。
+ *
+ * :returns: 候选列表 ref 与默认每页条数 ref
+ */
+export function usePageSizeOptions() {
+ if (!loaded) {
+ void loadPageSizeOptions()
+ }
+ return { pageSizeOptions, defaultPageSize }
+}
diff --git a/frontend/src/composables/useTablePageSize.ts b/frontend/src/composables/useTablePageSize.ts
new file mode 100644
index 0000000..52ef85e
--- /dev/null
+++ b/frontend/src/composables/useTablePageSize.ts
@@ -0,0 +1,20 @@
+// 表格每页大小偏好:优先取本机上次选择,其次取全局默认值,变更自动持久化
+import { ref, watch } from 'vue'
+import type { Ref } from 'vue'
+import { usePageSizeOptions } from './usePageSizeOptions'
+import { readTablePageSize, writeTablePageSize } from '../utils/tablePageSizePrefs'
+
+/**
+ * 管理指定表格的每页大小:初始化时读取本机偏好(须在候选列表内),否则回退全局默认值,变更自动持久化。
+ *
+ * :param tableKey: 表格唯一键
+ * :returns: 与存储同步的每页大小 ref
+ */
+export function useTablePageSize(tableKey: string): Ref {
+ const { pageSizeOptions, defaultPageSize } = usePageSizeOptions()
+ const saved = readTablePageSize(tableKey)
+ const initial = saved !== null && pageSizeOptions.value.includes(saved) ? saved : defaultPageSize.value
+ const size = ref(initial)
+ watch(size, (next) => writeTablePageSize(tableKey, next))
+ return size
+}
diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts
index 57d4793..7995304 100644
--- a/frontend/src/locales/messages.ts
+++ b/frontend/src/locales/messages.ts
@@ -196,6 +196,13 @@ export default {
hiddenDataHint: { zh: '关闭时,已隐藏备注的记录不会出现在收支记录列表与导出中', en: 'When off, records with hidden remarks are excluded from the transaction list and export', ja: 'オフの場合、備考を非表示にした記録は収支リストとエクスポートに表示されません' },
hiddenDataSaved: { zh: '已更新隐藏备注记录的显示设置', en: 'Hidden remark record display updated', ja: '非表示備考の記録表示を更新しました' },
hiddenDataFailed: { zh: '更新隐藏备注记录的显示设置失败', en: 'Failed to update hidden remark record display', ja: '非表示備考の記録表示の更新に失敗しました' },
+ pageSizeLabel: { zh: '表格每页条数选项', en: 'Table page size options', ja: 'テーブルの1ページ件数オプション' },
+ pageSizeHint: { zh: '所有表格分页共用的每页条数候选列表,未配置时使用默认 10 / 20 / 50 / 100', en: 'Page size choices shared by all table pagination; defaults to 10 / 20 / 50 / 100 when unset', ja: 'すべてのテーブルで共通の1ページ件数候補。未設定時は 10 / 20 / 50 / 100 を使用' },
+ defaultPageSizeLabel: { zh: '默认每页条数', en: 'Default page size', ja: '既定の1ページ件数' },
+ pageSizeSave: { zh: '保存', en: 'Save', ja: '保存' },
+ pageSizeEmpty: { zh: '请至少填写一个每页条数', en: 'Enter at least one page size', ja: '1ページ件数を1つ以上入力してください' },
+ pageSizeSaved: { zh: '每页条数选项已保存', en: 'Page size options saved', ja: '1ページ件数オプションを保存しました' },
+ pageSizeFailed: { zh: '保存每页条数选项失败', en: 'Failed to save page size options', ja: '1ページ件数オプションの保存に失敗しました' },
},
// 注册设置
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index b3f0027..2a00c81 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -5,6 +5,7 @@ import { useDatabaseStore } from '../store/database'
import { useRegistrationStore } from '../store/registration'
import { migrateKey, readString } from '../utils/storage'
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
+import { loadPageSizeOptions } from '../composables/usePageSizeOptions'
// 创建路由实例,使用 HTML5 History 模式
const router = createRouter({
@@ -165,9 +166,14 @@ router.beforeEach(async (to, _from, next) => {
// 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页并记录来源,登录后可回跳
if (to.path !== '/login' && to.path !== '/register' && !userStore.token) {
next({ path: '/login', query: { redirect: to.fullPath } })
- } else {
- next()
+ return
}
+
+ // 已登录:预加载表格分页偏好,保证表格页首次渲染即拿到默认每页条数
+ if (userStore.token) {
+ await loadPageSizeOptions()
+ }
+ next()
})
export default router
diff --git a/frontend/src/utils/storageKeys.ts b/frontend/src/utils/storageKeys.ts
index ae2e1d5..890663b 100644
--- a/frontend/src/utils/storageKeys.ts
+++ b/frontend/src/utils/storageKeys.ts
@@ -24,6 +24,8 @@ export const STORAGE_KEYS = {
transactionRemarkHistory: 'finance.transaction.remarkHistory',
// 收支记录筛选偏好(收支记录页与统计面板共用)
transactionFilterPrefs: 'finance.transaction.filterPrefs',
+ // 各表格每页大小偏好(聚合 JSON,按表格键区分,本机记忆)
+ paginationTablePageSizes: 'finance.pagination.tablePageSizes',
// 账单关系类型
transactionRelationType: 'finance.transaction.relationType',
// 收支记录 AI 查询历史
diff --git a/frontend/src/utils/tablePageSizePrefs.ts b/frontend/src/utils/tablePageSizePrefs.ts
new file mode 100644
index 0000000..e426ae4
--- /dev/null
+++ b/frontend/src/utils/tablePageSizePrefs.ts
@@ -0,0 +1,34 @@
+// 各表格每页大小偏好:以聚合 JSON 记录每个表格上次选择的每页条数,本机记忆
+import { readJson, writeJson } from './storage'
+import { STORAGE_KEYS } from './storageKeys'
+
+/** 表格键到每页大小的映射 */
+export type TablePageSizes = Record
+
+/** 读取全部表格的每页大小偏好,仅保留合法正整数 */
+export function readTablePageSizes(): TablePageSizes {
+ const raw = readJson>(STORAGE_KEYS.paginationTablePageSizes, {})
+ const result: TablePageSizes = {}
+ if (raw && typeof raw === 'object') {
+ for (const [key, value] of Object.entries(raw)) {
+ const size = Number(value)
+ if (Number.isInteger(size) && size > 0) {
+ result[key] = size
+ }
+ }
+ }
+ return result
+}
+
+/** 读取指定表格上次选择的每页大小,无有效值时返回 null */
+export function readTablePageSize(tableKey: string): number | null {
+ const all = readTablePageSizes()
+ return all[tableKey] ?? null
+}
+
+/** 写入指定表格的每页大小,保留其它表格的值 */
+export function writeTablePageSize(tableKey: string, size: number): void {
+ const all = readTablePageSizes()
+ all[tableKey] = size
+ writeJson(STORAGE_KEYS.paginationTablePageSizes, all)
+}
diff --git a/frontend/src/views/CategoryManage.vue b/frontend/src/views/CategoryManage.vue
index 23e6ec4..cea34f8 100644
--- a/frontend/src/views/CategoryManage.vue
+++ b/frontend/src/views/CategoryManage.vue
@@ -4,14 +4,19 @@ import { ref, onMounted, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
-import { getCategoryList, createCategory, updateCategory, deleteCategory } from '../api/category'
+import { getCategoryPage, createCategory, updateCategory, deleteCategory } from '../api/category'
import { getAiConfig, suggestEmoji, parseCategoryByAi } from '../api/ai'
import EmojiPicker from '../components/EmojiPicker.vue'
+import { usePageSizeOptions } from '../composables/usePageSizeOptions'
+import { useTablePageSize } from '../composables/useTablePageSize'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const { t } = useI18n() // 国际化
+// 所有表格共用的每页条数候选列表
+const { pageSizeOptions } = usePageSizeOptions()
+
/** 分类数据结构 */
interface CategoryItem {
id: number
@@ -23,8 +28,36 @@ interface CategoryItem {
}
// 列表和弹窗状态
-const categories = ref([]) // 全部分类列表
+const expenseList = ref([]) // 支出分类列表(当前页)
+const incomeList = ref([]) // 收入分类列表(当前页)
const loading = ref(false) // 列表加载状态
+
+// 支出分类分页
+const expensePage = ref(1)
+const expenseSize = useTablePageSize('categoryExpense')
+const expenseTotal = ref(0)
+// 收入分类分页
+const incomePage = ref(1)
+const incomeSize = useTablePageSize('categoryIncome')
+const incomeTotal = ref(0)
+
+/** 支出分类分页配置 */
+const expensePagination = computed(() => ({
+ page: expensePage.value,
+ pageSize: expenseSize.value,
+ itemCount: expenseTotal.value,
+ pageSizes: pageSizeOptions.value,
+ showSizePicker: true,
+}))
+
+/** 收入分类分页配置 */
+const incomePagination = computed(() => ({
+ page: incomePage.value,
+ pageSize: incomeSize.value,
+ itemCount: incomeTotal.value,
+ pageSizes: pageSizeOptions.value,
+ showSizePicker: true,
+}))
const submitting = ref(false) // 表单提交状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const editingId = ref(null) // 当前编辑的分类 ID(null 表示新增)
@@ -40,10 +73,6 @@ const typeOptions = computed(() => [
{ label: t('common.types.income'), value: 'INCOME' },
])
-// 按类型过滤分类列表
-const incomeList = computed(() => categories.value.filter(c => c.type === 'INCOME')) // 收入分类
-const expenseList = computed(() => categories.value.filter(c => c.type === 'EXPENSE')) // 支出分类
-
/** 创建表格列配置 */
function createColumns(): DataTableColumns {
return [
@@ -85,19 +114,64 @@ function createColumns(): DataTableColumns {
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
-/** 加载分类列表数据 */
+/** 加载支出分类当前页 */
+async function fetchExpense() {
+ try {
+ const res: any = await getCategoryPage({ type: 'EXPENSE', page: expensePage.value, size: expenseSize.value })
+ expenseList.value = res.data?.records || []
+ expenseTotal.value = res.data?.total || 0
+ } catch (e: any) {
+ message.error(e.message || t('common.loadFailed'))
+ }
+}
+
+/** 加载收入分类当前页 */
+async function fetchIncome() {
+ try {
+ const res: any = await getCategoryPage({ type: 'INCOME', page: incomePage.value, size: incomeSize.value })
+ incomeList.value = res.data?.records || []
+ incomeTotal.value = res.data?.total || 0
+ } catch (e: any) {
+ message.error(e.message || t('common.loadFailed'))
+ }
+}
+
+/** 同时加载收入与支出分类列表 */
async function fetchData() {
loading.value = true
try {
- const res: any = await getCategoryList()
- categories.value = res.data
- } catch (e: any) {
- message.error(e.message || t('common.loadFailed'))
+ await Promise.all([fetchExpense(), fetchIncome()])
} finally {
loading.value = false
}
}
+/** 支出分类翻页 */
+function handleExpensePageChange(p: number) {
+ expensePage.value = p
+ fetchExpense()
+}
+
+/** 支出分类每页条数变化 */
+function handleExpensePageSizeChange(size: number) {
+ expenseSize.value = size
+ expensePage.value = 1
+ fetchExpense()
+}
+
+/** 收入分类翻页 */
+function handleIncomePageChange(p: number) {
+ incomePage.value = p
+ fetchIncome()
+}
+
+/** 收入分类每页条数变化 */
+function handleIncomePageSizeChange(size: number) {
+ incomeSize.value = size
+ incomePage.value = 1
+ fetchIncome()
+}
+
/** 打开新增分类弹窗,重置表单 */
function handleAdd() {
editingId.value = null
@@ -229,11 +303,29 @@ onMounted(() => {
{{ t('category.expenseSection') }}
-
+
{{ t('category.incomeSection') }}
-
+
diff --git a/frontend/src/views/ChannelManage.vue b/frontend/src/views/ChannelManage.vue
index ff576ef..ab7ee1c 100644
--- a/frontend/src/views/ChannelManage.vue
+++ b/frontend/src/views/ChannelManage.vue
@@ -6,17 +6,36 @@ import { useRouter } from 'vue-router'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NAlert, NTag, NIcon } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { ArrowBackOutline } from '@vicons/ionicons5'
-import { getChannelList, createChannel, renameChannel, deleteChannel } from '../api/channel'
+import { getChannelPage, createChannel, renameChannel, deleteChannel } from '../api/channel'
import type { ChannelItem } from '../api/channel'
+import { usePageSizeOptions } from '../composables/usePageSizeOptions'
+import { useTablePageSize } from '../composables/useTablePageSize'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const router = useRouter() // 路由
const { t } = useI18n() // 国际化
+// 所有表格共用的每页条数候选列表
+const { pageSizeOptions } = usePageSizeOptions()
+
// 列表和弹窗状态
-const channels = ref([]) // 自定义渠道列表
+const channels = ref([]) // 自定义渠道列表(当前页)
const loading = ref(false) // 列表加载状态
+
+// 渠道列表分页
+const page = ref(1)
+const pageSize = useTablePageSize('channel')
+const total = ref(0)
+
+/** 渠道列表分页配置 */
+const pagination = computed(() => ({
+ page: page.value,
+ pageSize: pageSize.value,
+ itemCount: total.value,
+ pageSizes: pageSizeOptions.value,
+ showSizePicker: true,
+}))
const submitting = ref(false) // 表单提交状态
const showModal = ref(false) // 新增/改名弹窗显示状态
const editingId = ref(null) // 当前改名的渠道 ID(null 表示新增)
@@ -54,8 +73,9 @@ const columns = computed(() => createColumns()) // 表格列配置(随语言
async function fetchData() {
loading.value = true
try {
- const res: any = await getChannelList()
- channels.value = res.data || []
+ const res: any = await getChannelPage({ page: page.value, size: pageSize.value })
+ channels.value = res.data?.records || []
+ total.value = res.data?.total || 0
} catch (e: any) {
message.error(e.message || t('common.loadFailed'))
} finally {
@@ -63,6 +83,19 @@ async function fetchData() {
}
}
+/** 渠道列表翻页 */
+function handlePageChange(p: number) {
+ page.value = p
+ fetchData()
+}
+
+/** 渠道列表每页条数变化 */
+function handlePageSizeChange(size: number) {
+ pageSize.value = size
+ page.value = 1
+ fetchData()
+}
+
/** 打开新增渠道弹窗,重置表单 */
function handleAdd() {
editingId.value = null
@@ -147,7 +180,16 @@ onMounted(fetchData)
{{ t('channel.hint') }}
-
+
diff --git a/frontend/src/views/EmailBillManage.vue b/frontend/src/views/EmailBillManage.vue
index 0ec5eef..53a5bba 100644
--- a/frontend/src/views/EmailBillManage.vue
+++ b/frontend/src/views/EmailBillManage.vue
@@ -9,9 +9,9 @@ import {
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import {
- listEmailBillConfigs, createEmailBillConfig, updateEmailBillConfig, deleteEmailBillConfig,
+ pageEmailBillConfigs, createEmailBillConfig, updateEmailBillConfig, deleteEmailBillConfig,
toggleEmailBillConfig, testEmailBillConfig,
- listEmailBillSenderRules, createEmailBillSenderRule, updateEmailBillSenderRule,
+ pageEmailBillSenderRules, createEmailBillSenderRule, updateEmailBillSenderRule,
deleteEmailBillSenderRule, toggleEmailBillSenderRule,
pageEmailBillMessages, parseEmailBillMessage, importEmailBillMessage,
ignoreEmailBillMessage, deleteEmailBillMessage,
@@ -31,6 +31,8 @@ import BillImportResult from '../components/BillImportResult.vue'
import { openSse } from '../utils/sse'
import { migrateKey, writeString } from '../utils/storage'
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
+import { usePageSizeOptions } from '../composables/usePageSizeOptions'
+import { useTablePageSize } from '../composables/useTablePageSize'
const message = useMessage()
const dialog = useDialog()
@@ -38,6 +40,9 @@ const { t } = useI18n()
const route = useRoute()
const router = useRouter()
+// 所有表格共用的每页条数候选列表
+const { pageSizeOptions } = usePageSizeOptions()
+
/** 待处理列表自动刷新间隔(毫秒) */
const MESSAGE_REFRESH_MS = 30000
@@ -128,11 +133,23 @@ async function loadOptions() {
// ===== 邮箱配置 =====
const configs = ref([])
const configLoading = ref(false)
+const configPage = ref(1)
+const configSize = useTablePageSize('emailConfig')
+const configTotal = ref(0)
const configModalVisible = ref(false)
const configEditingId = ref(null)
const configForm = ref(emptyConfigForm())
const configSaving = ref(false) // 邮箱配置保存状态
+/** 邮箱配置分页配置 */
+const configPagination = computed(() => ({
+ page: configPage.value,
+ pageSize: configSize.value,
+ itemCount: configTotal.value,
+ pageSizes: pageSizeOptions.value,
+ showSizePicker: true,
+}))
+
// ===== 立即检查实时进度 =====
const checkLogs = ref([])
const checkRunning = ref(false)
@@ -207,8 +224,9 @@ const configColumns = computed>(() => [
async function loadConfigs() {
configLoading.value = true
try {
- const res: any = await listEmailBillConfigs()
- configs.value = res.data || []
+ const res: any = await pageEmailBillConfigs({ page: configPage.value, size: configSize.value })
+ configs.value = res.data?.records || []
+ configTotal.value = res.data?.total || 0
} catch (e: any) {
message.error(e.message || t('emailBill.common.loadFailed'))
} finally {
@@ -216,6 +234,19 @@ async function loadConfigs() {
}
}
+/** 邮箱配置翻页 */
+function handleConfigPageChange(p: number) {
+ configPage.value = p
+ loadConfigs()
+}
+
+/** 邮箱配置每页条数变化 */
+function handleConfigPageSizeChange(size: number) {
+ configSize.value = size
+ configPage.value = 1
+ loadConfigs()
+}
+
function openConfigModal(row?: EmailBillConfig) {
if (row) {
configEditingId.value = row.id
@@ -354,11 +385,23 @@ function stopCheckStream() {
// ===== 发件人规则 =====
const senderRules = ref([])
const ruleLoading = ref(false)
+const rulePage = ref(1)
+const ruleSize = useTablePageSize('emailRule')
+const ruleTotal = ref(0)
const ruleModalVisible = ref(false)
const ruleEditingId = ref(null)
const ruleForm = ref & { patternsText: string }>(emptyRuleForm())
const ruleSaving = ref(false) // 发件人规则保存状态
+/** 发件人规则分页配置 */
+const rulePagination = computed(() => ({
+ page: rulePage.value,
+ pageSize: ruleSize.value,
+ itemCount: ruleTotal.value,
+ pageSizes: pageSizeOptions.value,
+ showSizePicker: true,
+}))
+
function emptyRuleForm() {
return {
name: '', enabled: true, sortOrder: 0, patternsText: '', billType: defaultBillType(),
@@ -398,8 +441,9 @@ const ruleColumns = computed>(() => [
async function loadSenderRules() {
ruleLoading.value = true
try {
- const res: any = await listEmailBillSenderRules()
- senderRules.value = res.data || []
+ const res: any = await pageEmailBillSenderRules({ page: rulePage.value, size: ruleSize.value })
+ senderRules.value = res.data?.records || []
+ ruleTotal.value = res.data?.total || 0
} catch (e: any) {
message.error(e.message || t('emailBill.common.loadFailed'))
} finally {
@@ -407,6 +451,19 @@ async function loadSenderRules() {
}
}
+/** 发件人规则翻页 */
+function handleRulePageChange(p: number) {
+ rulePage.value = p
+ loadSenderRules()
+}
+
+/** 发件人规则每页条数变化 */
+function handleRulePageSizeChange(size: number) {
+ ruleSize.value = size
+ rulePage.value = 1
+ loadSenderRules()
+}
+
function openRuleModal(row?: EmailBillSenderRule) {
if (row) {
ruleEditingId.value = row.id
@@ -490,7 +547,7 @@ const messages = ref([])
const messageLoading = ref(false)
const messageStatus = ref('PULLED')
const messagePage = ref(1)
-const messageSize = ref(20)
+const messageSize = useTablePageSize('emailMessage')
const messageTotal = ref(0)
const messageColumns = computed>(() => [
@@ -704,7 +761,18 @@ onUnmounted(() => {
{{ t('emailBill.common.add') }}
-
+
{
{{ t('emailBill.common.add') }}
-
+
@@ -758,8 +837,10 @@ onUnmounted(() => {
:bordered="false"
remote
:pagination="{
- page: messagePage, pageSize: messageSize, itemCount: messageTotal, showSizePicker: false,
+ page: messagePage, pageSize: messageSize, itemCount: messageTotal,
+ pageSizes: pageSizeOptions, showSizePicker: true,
onUpdatePage: (p: number) => { messagePage = p; loadMessages() },
+ onUpdatePageSize: (s: number) => { messageSize = s; messagePage = 1; loadMessages() },
}"
/>
diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue
index e61ef03..e18fb63 100644
--- a/frontend/src/views/Logs.vue
+++ b/frontend/src/views/Logs.vue
@@ -14,12 +14,17 @@ import type { HttpLog, LoginLog, AppLog } from '../api/log'
import { openSse } from '../utils/sse'
import { migrateKey, writeString } from '../utils/storage'
import { LEGACY_STORAGE_KEYS, STORAGE_KEYS } from '../utils/storageKeys'
+import { usePageSizeOptions } from '../composables/usePageSizeOptions'
+import { useTablePageSize } from '../composables/useTablePageSize'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const { t } = useI18n()
+// 所有表格共用的每页条数候选列表
+const { pageSizeOptions } = usePageSizeOptions()
+
// 当前激活的标签页,取自 URL 子路径(/logs/http、/logs/login、/logs/app)
const activeTab = ref((route.params.tab as string) || 'http')
@@ -43,7 +48,7 @@ const retentionOptions = computed(() => [
const httpRecords = ref([])
const httpLoading = ref(false)
const httpPage = ref(1)
-const httpSize = ref(20)
+const httpSize = useTablePageSize('logsHttp')
const httpTotal = ref(0)
const httpKeyword = ref('')
const httpMethod = ref(null)
@@ -53,7 +58,7 @@ const httpStatus = ref(null)
const loginRecords = ref([])
const loginLoading = ref(false)
const loginPage = ref(1)
-const loginSize = ref(20)
+const loginSize = useTablePageSize('logsLogin')
const loginTotal = ref(0)
const loginKeyword = ref('')
const loginSuccess = ref<'true' | 'false' | null>(null)
@@ -473,7 +478,7 @@ function lineClass(line: string) {
v-model:page="httpPage"
v-model:page-size="httpSize"
:item-count="httpTotal"
- :page-sizes="[10, 20, 50, 100]"
+ :page-sizes="pageSizeOptions"
show-size-picker
@update:page="loadHttp"
@update:page-size="searchHttp"
@@ -527,7 +532,7 @@ function lineClass(line: string) {
v-model:page="loginPage"
v-model:page-size="loginSize"
:item-count="loginTotal"
- :page-sizes="[10, 20, 50, 100]"
+ :page-sizes="pageSizeOptions"
show-size-picker
@update:page="loadLogin"
@update:page-size="searchLogin"
diff --git a/frontend/src/views/RuleManage.vue b/frontend/src/views/RuleManage.vue
index a137805..c16cbc2 100644
--- a/frontend/src/views/RuleManage.vue
+++ b/frontend/src/views/RuleManage.vue
@@ -16,11 +16,16 @@ import { getAiConfig, suggestRulesByAi, applyAiRules } from '../api/ai'
import type { AiRuleSuggestion } from '../api/ai'
import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render'
+import { usePageSizeOptions } from '../composables/usePageSizeOptions'
+import { useTablePageSize } from '../composables/useTablePageSize'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const { t } = useI18n() // 国际化
+// 所有表格共用的每页条数候选列表
+const { pageSizeOptions } = usePageSizeOptions()
+
/** 分类选项 */
interface CategoryOption {
label: string
@@ -47,7 +52,7 @@ const tags = ref([])
// 规则列表分页与筛选
const page = ref(1)
-const pageSize = ref(20)
+const pageSize = useTablePageSize('rule')
const total = ref(0)
const searchKeyword = ref('')
const filterEnabled = ref(null) // 'ON' | 'OFF' | null
@@ -75,7 +80,7 @@ const aiCandidateRows = ref([])
const aiCandidateLoading = ref(false)
const checkedCandidateKeys = ref([])
const aiCandidatePage = ref(1)
-const aiCandidatePageSize = ref(20)
+const aiCandidatePageSize = useTablePageSize('ruleAiCandidate')
const aiCandidateTotal = ref(0)
const aiCandidateSearch = ref('')
const aiCandidateType = ref(null)
@@ -319,7 +324,7 @@ const aiCandidatePagination = computed(() => ({
page: aiCandidatePage.value,
pageSize: aiCandidatePageSize.value,
itemCount: aiCandidateTotal.value,
- pageSizes: [10, 20, 50, 100],
+ pageSizes: pageSizeOptions.value,
showSizePicker: true,
}))
@@ -554,7 +559,7 @@ const pagination = computed(() => ({
page: page.value,
pageSize: pageSize.value,
itemCount: total.value,
- pageSizes: [10, 20, 50, 100],
+ pageSizes: pageSizeOptions.value,
showSizePicker: true,
}))
diff --git a/frontend/src/views/TagManage.vue b/frontend/src/views/TagManage.vue
index d669a1d..2f34a5c 100644
--- a/frontend/src/views/TagManage.vue
+++ b/frontend/src/views/TagManage.vue
@@ -4,13 +4,18 @@ import { ref, onMounted, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NColorPicker, NTag } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
-import { getTagList, createTag, updateTag, deleteTag } from '../api/tag'
+import { getTagPage, createTag, updateTag, deleteTag } from '../api/tag'
import { getAiConfig, suggestColor } from '../api/ai'
+import { usePageSizeOptions } from '../composables/usePageSizeOptions'
+import { useTablePageSize } from '../composables/useTablePageSize'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const { t } = useI18n() // 国际化
+// 所有表格共用的每页条数候选列表
+const { pageSizeOptions } = usePageSizeOptions()
+
/** 标签数据结构 */
interface TagItem {
id: number
@@ -21,8 +26,22 @@ interface TagItem {
}
// 列表和弹窗状态
-const tags = ref([]) // 标签列表
+const tags = ref([]) // 标签列表(当前页)
const loading = ref(false) // 列表加载状态
+
+// 标签列表分页
+const page = ref(1)
+const pageSize = useTablePageSize('tag')
+const total = ref(0)
+
+/** 标签列表分页配置 */
+const pagination = computed(() => ({
+ page: page.value,
+ pageSize: pageSize.value,
+ itemCount: total.value,
+ pageSizes: pageSizeOptions.value,
+ showSizePicker: true,
+}))
const submitting = ref(false) // 表单提交状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const editingId = ref(null) // 当前编辑的标签 ID(null 表示新增)
@@ -82,8 +101,9 @@ const columns = computed(() => createColumns()) // 表格列配置(随语言
async function fetchData() {
loading.value = true
try {
- const res: any = await getTagList()
- tags.value = res.data
+ const res: any = await getTagPage({ page: page.value, size: pageSize.value })
+ tags.value = res.data?.records || []
+ total.value = res.data?.total || 0
} catch (e: any) {
message.error(e.message || t('common.loadFailed'))
} finally {
@@ -91,6 +111,19 @@ async function fetchData() {
}
}
+/** 标签列表翻页 */
+function handlePageChange(p: number) {
+ page.value = p
+ fetchData()
+}
+
+/** 标签列表每页条数变化 */
+function handlePageSizeChange(size: number) {
+ pageSize.value = size
+ page.value = 1
+ fetchData()
+}
+
/** 打开新增标签弹窗,重置表单 */
function handleAdd() {
editingId.value = null
@@ -192,7 +225,16 @@ onMounted(() => {
-
+
diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue
index a3e425e..1417c08 100644
--- a/frontend/src/views/TransactionList.vue
+++ b/frontend/src/views/TransactionList.vue
@@ -17,6 +17,9 @@ import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import { useStringPreference } from '../composables/usePreference'
+import { usePageSizeOptions } from '../composables/usePageSizeOptions'
+import { useTablePageSize } from '../composables/useTablePageSize'
+import { readTablePageSize, writeTablePageSize } from '../utils/tablePageSizePrefs'
import { openSse } from '../utils/sse'
import { STORAGE_KEYS } from '../utils/storageKeys'
import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs'
@@ -94,7 +97,7 @@ interface AiRecordEditRow {
const transactions = ref([]) // 交易记录列表
const total = ref(0) // 总记录数
const page = ref(1) // 当前页码
-const pageSize = ref(20) // 每页条数
+const pageSize = useTablePageSize('transaction') // 每页条数
const sortValue = ref('transactionTime-desc') // 排序方式:排序字段-方向
const sortOptions = computed(() => [
{ label: t('transaction.sort.timeDesc'), value: 'transactionTime-desc' },
@@ -104,7 +107,7 @@ const sortOptions = computed(() => [
])
const sortField = computed(() => sortValue.value.split('-')[0]) // 服务端排序字段
const sortOrder = computed(() => sortValue.value.split('-')[1]) // 服务端排序方向:asc/desc
-const pageSizes = [10, 20, 50, 100, 200, 500] // 可选的每页条数
+const { pageSizeOptions } = usePageSizeOptions() // 所有表格共用的每页条数候选列表
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) // 总页数
// 当前页金额汇总:支出、收入与结余(含本页所有展示记录)
const pageSummary = computed(() => {
@@ -345,7 +348,12 @@ function loadFilterPrefs() {
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
if (p.filterMinAmount !== undefined) filterMinAmount.value = p.filterMinAmount
if (p.filterMaxAmount !== undefined) filterMaxAmount.value = p.filterMaxAmount
- if (typeof p.pageSize === 'number' && pageSizes.includes(p.pageSize)) pageSize.value = p.pageSize
+ // 迁移旧筛选偏好中的每页条数到统一的表格分页偏好(新机制尚无值时)
+ if (typeof p.pageSize === 'number' && Number.isInteger(p.pageSize) && p.pageSize > 0
+ && readTablePageSize('transaction') === null) {
+ writeTablePageSize('transaction', p.pageSize)
+ pageSize.value = p.pageSize
+ }
if (typeof p.sortValue === 'string' && sortOptions.value.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
if (p.filterTag) filterTag.value = p.filterTag
}
@@ -361,7 +369,6 @@ function saveFilterPrefs() {
filterRemark: filterRemark.value,
filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value,
- pageSize: pageSize.value,
sortValue: sortValue.value,
filterTag: filterTag.value,
})
@@ -369,7 +376,7 @@ function saveFilterPrefs() {
// 筛选条件变化时自动保存用户偏好
watch(
- [filterType, filterCategory, filterChannel, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
+ [filterType, filterCategory, filterChannel, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, sortValue, filterTag],
saveFilterPrefs,
{ deep: true },
)
@@ -2224,7 +2231,7 @@ onBeforeUnmount(() => {
v-model:page="page"
v-model:page-size="pageSize"
:page-count="Math.ceil(total / pageSize)"
- :page-sizes="pageSizes"
+ :page-sizes="pageSizeOptions"
show-size-picker
show-quick-jumper
@update:page="handlePageChange"
diff --git a/src/main/java/cn/rosercode/finance/controller/CategoryController.java b/src/main/java/cn/rosercode/finance/controller/CategoryController.java
index f78c3cb..20d3ce5 100644
--- a/src/main/java/cn/rosercode/finance/controller/CategoryController.java
+++ b/src/main/java/cn/rosercode/finance/controller/CategoryController.java
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.CategoryRequest;
import cn.rosercode.finance.service.CategoryService;
@@ -54,6 +55,24 @@ public class CategoryController {
return Result.success(categoryService.list(userId));
}
+ /**
+ * 分页获取分类列表
+ *
+ * @param type 分类类型(可选),用于按类型筛选
+ * @param page 页码
+ * @param size 每页大小
+ * @return 分页结果
+ */
+ @GetMapping("/page")
+ @Operation(summary = "分页获取分类列表", description = "分页查询当前用户的分类,支持按类型筛选")
+ public Result> page(
+ @Parameter(description = "分类类型,可选值:income/expense") @RequestParam(required = false) String type,
+ @Parameter(description = "页码") @RequestParam(defaultValue = "1") int page,
+ @Parameter(description = "每页大小") @RequestParam(defaultValue = "20") int size) {
+ Long userId = UserContext.getUserId();
+ return Result.success(categoryService.page(userId, type, page, size));
+ }
+
/**
* 创建分类
*
diff --git a/src/main/java/cn/rosercode/finance/controller/ChannelController.java b/src/main/java/cn/rosercode/finance/controller/ChannelController.java
index 4963d17..f41572c 100644
--- a/src/main/java/cn/rosercode/finance/controller/ChannelController.java
+++ b/src/main/java/cn/rosercode/finance/controller/ChannelController.java
@@ -12,8 +12,10 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.ChannelRequest;
import cn.rosercode.finance.service.ChannelService;
@@ -49,6 +51,21 @@ public class ChannelController {
return Result.success(channelService.list(UserContext.getUserId()));
}
+ /**
+ * 分页查询当前用户的自定义渠道列表
+ *
+ * @param page 页码
+ * @param size 每页大小
+ * @return 分页结果(含使用账单数)
+ */
+ @GetMapping("/page")
+ @Operation(summary = "分页渠道列表", description = "分页查询当前用户的自定义交易渠道,含各自被使用的账单数")
+ public Result> page(
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ return Result.success(channelService.page(UserContext.getUserId(), page, size));
+ }
+
/**
* 新建自定义渠道
*
diff --git a/src/main/java/cn/rosercode/finance/controller/EmailBillController.java b/src/main/java/cn/rosercode/finance/controller/EmailBillController.java
index a6d74af..cc383a8 100644
--- a/src/main/java/cn/rosercode/finance/controller/EmailBillController.java
+++ b/src/main/java/cn/rosercode/finance/controller/EmailBillController.java
@@ -79,6 +79,22 @@ public class EmailBillController {
return Result.success(configService.list(userId));
}
+ /**
+ * 分页查询邮箱配置列表
+ *
+ * @param page 页码
+ * @param size 每页大小
+ * @return 分页结果
+ */
+ @GetMapping("/configs/page")
+ @Operation(summary = "分页查询邮箱配置列表", description = "分页获取当前用户的 IMAP 邮箱配置,授权码已脱敏")
+ public Result> pageConfigs(
+ @Parameter(description = "页码") @RequestParam(defaultValue = "1") int page,
+ @Parameter(description = "每页大小") @RequestParam(defaultValue = "20") int size) {
+ Long userId = UserContext.getUserId();
+ return Result.success(configService.page(userId, page, size));
+ }
+
/**
* 创建邮箱配置
*
@@ -164,6 +180,22 @@ public class EmailBillController {
return Result.success(senderRuleService.list(userId));
}
+ /**
+ * 分页查询发件人规则列表
+ *
+ * @param page 页码
+ * @param size 每页大小
+ * @return 分页结果
+ */
+ @GetMapping("/sender-rules/page")
+ @Operation(summary = "分页查询发件人规则列表", description = "分页获取当前用户的发件人识别规则,按优先级排序")
+ public Result> pageSenderRules(
+ @Parameter(description = "页码") @RequestParam(defaultValue = "1") int page,
+ @Parameter(description = "每页大小") @RequestParam(defaultValue = "20") int size) {
+ Long userId = UserContext.getUserId();
+ return Result.success(senderRuleService.page(userId, page, size));
+ }
+
/**
* 创建发件人规则
*
diff --git a/src/main/java/cn/rosercode/finance/controller/TagController.java b/src/main/java/cn/rosercode/finance/controller/TagController.java
index f8f4cb5..4dcf2cd 100644
--- a/src/main/java/cn/rosercode/finance/controller/TagController.java
+++ b/src/main/java/cn/rosercode/finance/controller/TagController.java
@@ -13,8 +13,10 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.TagRequest;
import cn.rosercode.finance.service.TagService;
@@ -51,6 +53,22 @@ public class TagController {
return Result.success(tagService.list(userId));
}
+ /**
+ * 分页获取标签列表
+ *
+ * @param page 页码
+ * @param size 每页大小
+ * @return 分页结果
+ */
+ @GetMapping("/page")
+ @Operation(summary = "分页获取标签列表", description = "分页查询当前用户的标签")
+ public Result> page(
+ @Parameter(description = "页码") @RequestParam(defaultValue = "1") int page,
+ @Parameter(description = "每页大小") @RequestParam(defaultValue = "20") int size) {
+ Long userId = UserContext.getUserId();
+ return Result.success(tagService.page(userId, page, size));
+ }
+
/**
* 创建标签
*
diff --git a/src/main/java/cn/rosercode/finance/controller/UserSettingController.java b/src/main/java/cn/rosercode/finance/controller/UserSettingController.java
index 6bde85c..78bdd65 100644
--- a/src/main/java/cn/rosercode/finance/controller/UserSettingController.java
+++ b/src/main/java/cn/rosercode/finance/controller/UserSettingController.java
@@ -13,15 +13,17 @@ import org.springframework.web.bind.annotation.RestController;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.BillSourceStatsRequest;
import cn.rosercode.finance.dto.ExcludedDisplayStyleRequest;
+import cn.rosercode.finance.dto.PaginationPrefsRequest;
import cn.rosercode.finance.dto.ShowHiddenDataRequest;
import cn.rosercode.finance.service.UserPreferenceService;
import cn.rosercode.finance.util.UserContext;
+import cn.rosercode.finance.vo.PaginationPrefsVO;
/**
* 用户设置控制器
*
- * 提供按当前登录用户存储的偏好配置读写接口,包含账单来源统计默认值与
- * 「不计入统计」记录的展示样式。
+ * 提供按当前登录用户存储的偏好配置读写接口,包含账单来源统计默认值、
+ * 「不计入统计」记录的展示样式与表格分页偏好。
*
* @date 2026-09-21 10:14:12
*/
@@ -111,4 +113,32 @@ public class UserSettingController {
userPreferenceService.saveShowHiddenData(userId, show);
return Result.success(userPreferenceService.getShowHiddenData(userId));
}
+
+ /**
+ * 查询当前用户配置的表格分页偏好。
+ *
+ * @return 分页偏好(每页条数候选列表与默认每页条数),未配置时返回系统默认
+ */
+ @GetMapping("/pagination-prefs")
+ @Operation(summary = "查询分页偏好", description = "读取当前用户所有表格分页的每页条数候选列表与默认每页条数")
+ public Result getPaginationPrefs() {
+ return Result.success(userPreferenceService.getPaginationPrefs(UserContext.getUserId()));
+ }
+
+ /**
+ * 更新当前用户配置的表格分页偏好。
+ *
+ * @param request 分页偏好更新请求
+ * @return 更新后的分页偏好,空或非法时回退系统默认
+ */
+ @PutMapping("/pagination-prefs")
+ @Operation(summary = "更新分页偏好", description = "设置当前用户所有表格分页的每页条数候选列表与默认每页条数")
+ public Result updatePaginationPrefs(@RequestBody PaginationPrefsRequest request) {
+ Long userId = UserContext.getUserId();
+ PaginationPrefsVO prefs = new PaginationPrefsVO();
+ prefs.setOptions(request.getOptions());
+ prefs.setDefaultSize(request.getDefaultSize());
+ userPreferenceService.savePaginationPrefs(userId, prefs);
+ return Result.success(userPreferenceService.getPaginationPrefs(userId));
+ }
}
diff --git a/src/main/java/cn/rosercode/finance/dto/PaginationPrefsRequest.java b/src/main/java/cn/rosercode/finance/dto/PaginationPrefsRequest.java
new file mode 100644
index 0000000..78d52f4
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/dto/PaginationPrefsRequest.java
@@ -0,0 +1,20 @@
+package cn.rosercode.finance.dto;
+
+import java.util.List;
+
+import lombok.Data;
+
+/**
+ * 表格分页偏好更新请求对象。
+ *
+ * @date 2026-09-23 11:05:25
+ */
+@Data
+public class PaginationPrefsRequest {
+
+ /** 每页条数候选列表 */
+ private List options;
+
+ /** 默认每页条数 */
+ private Integer defaultSize;
+}
diff --git a/src/main/java/cn/rosercode/finance/service/CategoryService.java b/src/main/java/cn/rosercode/finance/service/CategoryService.java
index db96c0f..b3e5204 100644
--- a/src/main/java/cn/rosercode/finance/service/CategoryService.java
+++ b/src/main/java/cn/rosercode/finance/service/CategoryService.java
@@ -2,6 +2,7 @@ package cn.rosercode.finance.service;
import java.util.List;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.dto.CategoryRequest;
import cn.rosercode.finance.vo.CategoryVO;
@@ -27,6 +28,17 @@ public interface CategoryService {
*/
List listByType(Long userId, String type);
+ /**
+ * 分页查询用户的分类,可按类型筛选。
+ *
+ * @param userId 用户ID
+ * @param type 分类类型(如 INCOME、EXPENSE),为空时查询全部
+ * @param page 页码,从 1 开始
+ * @param size 每页大小
+ * @return 分页结果
+ */
+ PageResult page(Long userId, String type, int page, int size);
+
/**
* 创建新分类
*
diff --git a/src/main/java/cn/rosercode/finance/service/ChannelService.java b/src/main/java/cn/rosercode/finance/service/ChannelService.java
index ea3f776..ceda90e 100644
--- a/src/main/java/cn/rosercode/finance/service/ChannelService.java
+++ b/src/main/java/cn/rosercode/finance/service/ChannelService.java
@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.dto.ChannelRequest;
import cn.rosercode.finance.vo.ChannelOptionVO;
import cn.rosercode.finance.vo.ChannelVO;
@@ -23,6 +24,16 @@ public interface ChannelService {
*/
List list(Long userId);
+ /**
+ * 分页查询当前用户的自定义渠道列表(含各自使用账单数)。
+ *
+ * @param userId 用户ID
+ * @param page 页码,从 1 开始
+ * @param size 每页大小
+ * @return 分页结果
+ */
+ PageResult page(Long userId, int page, int size);
+
/**
* 新建自定义渠道。
*
diff --git a/src/main/java/cn/rosercode/finance/service/EmailBillConfigService.java b/src/main/java/cn/rosercode/finance/service/EmailBillConfigService.java
index 29d5186..d5ce4ae 100644
--- a/src/main/java/cn/rosercode/finance/service/EmailBillConfigService.java
+++ b/src/main/java/cn/rosercode/finance/service/EmailBillConfigService.java
@@ -2,6 +2,7 @@ package cn.rosercode.finance.service;
import java.util.List;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.dto.EmailBillConfigRequest;
import cn.rosercode.finance.entity.EmailBillConfig;
import cn.rosercode.finance.vo.EmailBillConfigVO;
@@ -21,6 +22,16 @@ public interface EmailBillConfigService {
*/
List list(Long userId);
+ /**
+ * 分页查询当前用户的邮箱配置。
+ *
+ * @param userId 当前用户ID
+ * @param page 页码,从 1 开始
+ * @param size 每页大小
+ * @return 分页结果
+ */
+ PageResult page(Long userId, int page, int size);
+
/**
* 新增邮箱配置。
*
diff --git a/src/main/java/cn/rosercode/finance/service/EmailBillSenderRuleService.java b/src/main/java/cn/rosercode/finance/service/EmailBillSenderRuleService.java
index f256e64..a11e598 100644
--- a/src/main/java/cn/rosercode/finance/service/EmailBillSenderRuleService.java
+++ b/src/main/java/cn/rosercode/finance/service/EmailBillSenderRuleService.java
@@ -2,6 +2,7 @@ package cn.rosercode.finance.service;
import java.util.List;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.dto.EmailBillSenderRuleRequest;
import cn.rosercode.finance.entity.EmailBillSenderRule;
import cn.rosercode.finance.vo.EmailBillSenderRuleVO;
@@ -21,6 +22,16 @@ public interface EmailBillSenderRuleService {
*/
List list(Long userId);
+ /**
+ * 分页查询当前用户的发件人规则。
+ *
+ * @param userId 当前用户ID
+ * @param page 页码,从 1 开始
+ * @param size 每页大小
+ * @return 分页结果,按优先级升序
+ */
+ PageResult page(Long userId, int page, int size);
+
/**
* 新增发件人规则。
*
diff --git a/src/main/java/cn/rosercode/finance/service/TagService.java b/src/main/java/cn/rosercode/finance/service/TagService.java
index d4b7dcc..1b0e9b1 100644
--- a/src/main/java/cn/rosercode/finance/service/TagService.java
+++ b/src/main/java/cn/rosercode/finance/service/TagService.java
@@ -2,6 +2,7 @@ package cn.rosercode.finance.service;
import java.util.List;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.dto.TagRequest;
import cn.rosercode.finance.vo.TagVO;
@@ -20,6 +21,16 @@ public interface TagService {
*/
List list(Long userId);
+ /**
+ * 分页查询用户的标签列表。
+ *
+ * @param userId 用户ID
+ * @param page 页码,从 1 开始
+ * @param size 每页大小
+ * @return 分页结果
+ */
+ PageResult page(Long userId, int page, int size);
+
/**
* 创建新标签
*
diff --git a/src/main/java/cn/rosercode/finance/service/UserPreferenceService.java b/src/main/java/cn/rosercode/finance/service/UserPreferenceService.java
index beec330..1009ac2 100644
--- a/src/main/java/cn/rosercode/finance/service/UserPreferenceService.java
+++ b/src/main/java/cn/rosercode/finance/service/UserPreferenceService.java
@@ -2,6 +2,8 @@ package cn.rosercode.finance.service;
import java.util.Map;
+import cn.rosercode.finance.vo.PaginationPrefsVO;
+
/**
* 用户偏好服务接口,提供按用户存储的偏好配置读写。
*
@@ -56,4 +58,20 @@ public interface UserPreferenceService {
* @param show 是否显示
*/
void saveShowHiddenData(Long userId, boolean show);
+
+ /**
+ * 获取当前用户配置的表格分页偏好(每页条数候选列表与默认每页条数),未配置或非法时回退默认值。
+ *
+ * @param userId 用户ID
+ * @return 分页偏好
+ */
+ PaginationPrefsVO getPaginationPrefs(Long userId);
+
+ /**
+ * 保存当前用户配置的表格分页偏好,空或非法时回退默认值。
+ *
+ * @param userId 用户ID
+ * @param prefs 分页偏好
+ */
+ void savePaginationPrefs(Long userId, PaginationPrefsVO prefs);
}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/CategoryServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/CategoryServiceImpl.java
index f25d19d..646b28c 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/CategoryServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/CategoryServiceImpl.java
@@ -4,11 +4,13 @@ import java.util.List;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import cn.rosercode.finance.common.BusinessException;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.CategoryRequest;
import cn.rosercode.finance.entity.Category;
@@ -60,6 +62,28 @@ public class CategoryServiceImpl implements CategoryService {
return categories.stream().map(voConverter::toCategoryVO).collect(Collectors.toList());
}
+ /**
+ * {@inheritDoc}
+ *
+ * 按排序字段和ID升序分页,type 为空时查询全部类型。
+ */
+ @Override
+ public PageResult page(Long userId, String type, int page, int size) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(Category::getUserId, userId);
+ if (type != null && !type.isEmpty()) {
+ wrapper.eq(Category::getType, type);
+ }
+ wrapper.orderByAsc(Category::getSortOrder).orderByAsc(Category::getId);
+ int pageNo = page < 1 ? 1 : page;
+ int pageSize = size < 1 ? 20 : size;
+ Page result = categoryMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
+ List records = result.getRecords().stream()
+ .map(voConverter::toCategoryVO)
+ .collect(Collectors.toList());
+ return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());
+ }
+
/**
* {@inheritDoc}
*
diff --git a/src/main/java/cn/rosercode/finance/service/impl/ChannelServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/ChannelServiceImpl.java
index 5422f13..47d9eff 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/ChannelServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/ChannelServiceImpl.java
@@ -9,11 +9,13 @@ import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import cn.rosercode.finance.common.BusinessException;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.dto.ChannelRequest;
import cn.rosercode.finance.entity.Channel;
import cn.rosercode.finance.entity.Transaction;
@@ -69,16 +71,48 @@ public class ChannelServiceImpl implements ChannelService {
.orderByAsc(Channel::getId));
List result = new ArrayList<>();
for (Channel channel : channels) {
- ChannelVO vo = new ChannelVO();
- vo.setId(channel.getId());
- vo.setCode(channel.getCode());
- vo.setName(channel.getName());
- vo.setTransactionCount(countTransactions(userId, channel.getCode()));
- result.add(vo);
+ result.add(toChannelVO(userId, channel));
}
return result;
}
+ /**
+ * {@inheritDoc}
+ *
+ * 按排序字段和ID升序分页,并对当前页渠道逐条统计使用账单数。
+ */
+ @Override
+ public PageResult page(Long userId, int page, int size) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(Channel::getUserId, userId)
+ .orderByAsc(Channel::getSortOrder)
+ .orderByAsc(Channel::getId);
+ int pageNo = page < 1 ? 1 : page;
+ int pageSize = size < 1 ? 20 : size;
+ Page result = channelMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
+ List records = new ArrayList<>();
+ for (Channel channel : result.getRecords()) {
+ records.add(toChannelVO(userId, channel));
+ }
+ return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());
+ }
+
+ /**
+ * 将渠道实体转换为视图对象,并填充使用账单数。
+ *
+ * @param userId 用户ID
+ * @param channel 渠道实体
+ * @return 渠道视图对象
+ */
+ private ChannelVO toChannelVO(Long userId, Channel channel) {
+ ChannelVO vo = new ChannelVO();
+ vo.setId(channel.getId());
+ vo.setCode(channel.getCode());
+ vo.setName(channel.getName());
+ vo.setTransactionCount(countTransactions(userId, channel.getCode()));
+ return vo;
+ }
+
/**
* {@inheritDoc}
*
diff --git a/src/main/java/cn/rosercode/finance/service/impl/EmailBillConfigServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/EmailBillConfigServiceImpl.java
index 40b11cf..929479b 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/EmailBillConfigServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/EmailBillConfigServiceImpl.java
@@ -5,6 +5,7 @@ import java.util.ArrayList;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -17,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import cn.rosercode.finance.common.BusinessException;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.EmailBillConfigRequest;
import cn.rosercode.finance.email.EmailBillMonitorManager;
@@ -105,6 +107,26 @@ public class EmailBillConfigServiceImpl implements EmailBillConfigService {
return result;
}
+ /**
+ * {@inheritDoc}
+ *
+ * 按配置ID升序分页。
+ */
+ @Override
+ public PageResult page(Long userId, int page, int size) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(EmailBillConfig::getUserId, userId)
+ .orderByAsc(EmailBillConfig::getId);
+ int pageNo = page < 1 ? 1 : page;
+ int pageSize = size < 1 ? 20 : size;
+ Page result = configMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
+ List records = new ArrayList<>();
+ for (EmailBillConfig config : result.getRecords()) {
+ records.add(toVO(config));
+ }
+ return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());
+ }
+
/**
* {@inheritDoc}
*
diff --git a/src/main/java/cn/rosercode/finance/service/impl/EmailBillSenderRuleServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/EmailBillSenderRuleServiceImpl.java
index ac05475..ff1acfd 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/EmailBillSenderRuleServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/EmailBillSenderRuleServiceImpl.java
@@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -13,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import cn.rosercode.finance.common.BusinessException;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.EmailBillSenderRuleRequest;
import cn.rosercode.finance.entity.Category;
@@ -72,6 +74,27 @@ public class EmailBillSenderRuleServiceImpl implements EmailBillSenderRuleServic
return result;
}
+ /**
+ * {@inheritDoc}
+ *
+ * 按优先级和ID升序分页。
+ */
+ @Override
+ public PageResult page(Long userId, int page, int size) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(EmailBillSenderRule::getUserId, userId)
+ .orderByAsc(EmailBillSenderRule::getSortOrder)
+ .orderByAsc(EmailBillSenderRule::getId);
+ int pageNo = page < 1 ? 1 : page;
+ int pageSize = size < 1 ? 20 : size;
+ Page result = senderRuleMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
+ List records = new ArrayList<>();
+ for (EmailBillSenderRule rule : result.getRecords()) {
+ records.add(toVO(rule));
+ }
+ return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());
+ }
+
/**
* {@inheritDoc}
*/
diff --git a/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java
index 8889c77..c110758 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java
@@ -4,11 +4,13 @@ import java.util.List;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import cn.rosercode.finance.common.BusinessException;
+import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.TagRequest;
import cn.rosercode.finance.entity.RuleTag;
@@ -56,6 +58,26 @@ public class TagServiceImpl implements TagService {
return tags.stream().map(voConverter::toTagVO).collect(Collectors.toList());
}
+ /**
+ * {@inheritDoc}
+ *
+ * 按排序字段和ID升序分页。
+ */
+ @Override
+ public PageResult page(Long userId, int page, int size) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(Tag::getUserId, userId)
+ .orderByAsc(Tag::getSortOrder)
+ .orderByAsc(Tag::getId);
+ int pageNo = page < 1 ? 1 : page;
+ int pageSize = size < 1 ? 20 : size;
+ Page result = tagMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
+ List records = result.getRecords().stream()
+ .map(voConverter::toTagVO)
+ .collect(Collectors.toList());
+ return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());
+ }
+
/**
* {@inheritDoc}
*
diff --git a/src/main/java/cn/rosercode/finance/service/impl/UserPreferenceServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/UserPreferenceServiceImpl.java
index caeb66a..ddd6ef6 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/UserPreferenceServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/UserPreferenceServiceImpl.java
@@ -2,8 +2,10 @@ package cn.rosercode.finance.service.impl;
import java.util.Collections;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -19,6 +21,7 @@ import cn.rosercode.finance.entity.UserPreference;
import cn.rosercode.finance.enums.BillType;
import cn.rosercode.finance.mapper.UserPreferenceMapper;
import cn.rosercode.finance.service.UserPreferenceService;
+import cn.rosercode.finance.vo.PaginationPrefsVO;
/**
* 用户偏好服务实现类,以键值形式读写每个用户的偏好配置。
@@ -40,6 +43,24 @@ public class UserPreferenceServiceImpl implements UserPreferenceService {
/** 偏好键:是否显示已隐藏备注的记录 */
private static final String KEY_SHOW_HIDDEN_DATA = "showHiddenData";
+ /** 偏好键:表格分页偏好(每页条数候选列表与默认每页条数) */
+ private static final String KEY_PAGINATION_PREFS = "paginationPrefs";
+
+ /** 默认的表格分页每页条数候选列表 */
+ private static final List DEFAULT_PAGE_SIZE_OPTIONS = List.of(10, 20, 50, 100);
+
+ /** 默认每页条数 */
+ private static final int DEFAULT_PAGE_SIZE = 20;
+
+ /** 每页条数下限 */
+ private static final int MIN_PAGE_SIZE = 1;
+
+ /** 每页条数上限 */
+ private static final int MAX_PAGE_SIZE = 1000;
+
+ /** 每页条数候选值最大数量 */
+ private static final int MAX_PAGE_SIZE_OPTIONS = 10;
+
/** 默认计入统计的账单来源,其余来源默认不计入 */
private static final Set DEFAULT_INCLUDED_SOURCES = Set.of("WECHAT", "ALIPAY");
@@ -146,6 +167,59 @@ public class UserPreferenceServiceImpl implements UserPreferenceService {
log.info("保存隐藏备注记录显示偏好: userId={}, show={}", userId, show);
}
+ /**
+ * {@inheritDoc}
+ *
+ * 结果按用户缓存,未配置或取值非法时回退默认值。
+ */
+ @Override
+ @Cacheable(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'paginationPrefs:' + #userId")
+ public PaginationPrefsVO getPaginationPrefs(Long userId) {
+ return normalizePaginationPrefs(parsePaginationPrefs(findValue(userId, KEY_PAGINATION_PREFS)));
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 保存后失效该用户的分页偏好缓存。
+ */
+ @Override
+ @CacheEvict(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'paginationPrefs:' + #userId")
+ public void savePaginationPrefs(Long userId, PaginationPrefsVO prefs) {
+ PaginationPrefsVO normalized = normalizePaginationPrefs(prefs);
+ upsert(userId, KEY_PAGINATION_PREFS, toJson(normalized));
+ log.info("保存表格分页偏好: userId={}, options={}, defaultSize={}",
+ userId, normalized.getOptions(), normalized.getDefaultSize());
+ }
+
+ /**
+ * 规范化分页偏好:候选列表过滤非正整数与越界值、去重升序并限制数量(为空回退默认);
+ * 默认每页条数须为候选列表内的正整数,否则回退候选列表首项。
+ *
+ * @param prefs 原始分页偏好
+ * @return 规范化后的分页偏好
+ */
+ private PaginationPrefsVO normalizePaginationPrefs(PaginationPrefsVO prefs) {
+ List options = prefs != null ? prefs.getOptions() : null;
+ List normalizedOptions = options == null ? List.of() : options.stream()
+ .filter(value -> value != null && value >= MIN_PAGE_SIZE && value <= MAX_PAGE_SIZE)
+ .distinct()
+ .sorted()
+ .limit(MAX_PAGE_SIZE_OPTIONS)
+ .collect(Collectors.toList());
+ if (normalizedOptions.isEmpty()) {
+ normalizedOptions = DEFAULT_PAGE_SIZE_OPTIONS;
+ }
+ // 未配置或未传默认值时回退系统默认每页条数,再校验其是否在候选列表内
+ Integer rawDefault = prefs != null ? prefs.getDefaultSize() : null;
+ int defaultSize = rawDefault != null ? rawDefault : DEFAULT_PAGE_SIZE;
+ int resolvedDefault = normalizedOptions.contains(defaultSize) ? defaultSize : normalizedOptions.get(0);
+ PaginationPrefsVO result = new PaginationPrefsVO();
+ result.setOptions(List.copyOf(normalizedOptions));
+ result.setDefaultSize(resolvedDefault);
+ return result;
+ }
+
/**
* 查询指定用户的某个偏好值。
*
@@ -202,6 +276,24 @@ public class UserPreferenceServiceImpl implements UserPreferenceService {
}
}
+ /**
+ * 将 JSON 字符串解析为分页偏好,解析失败或为空时返回 null。
+ *
+ * @param json JSON 字符串
+ * @return 分页偏好,解析失败时为 null
+ */
+ private PaginationPrefsVO parsePaginationPrefs(String json) {
+ if (json == null || json.isBlank()) {
+ return null;
+ }
+ try {
+ return objectMapper.readValue(json, PaginationPrefsVO.class);
+ } catch (JsonProcessingException e) {
+ log.warn("解析表格分页偏好失败:{}", e.getMessage());
+ return null;
+ }
+ }
+
/**
* 将对象序列化为 JSON 字符串。
*
diff --git a/src/main/java/cn/rosercode/finance/vo/PaginationPrefsVO.java b/src/main/java/cn/rosercode/finance/vo/PaginationPrefsVO.java
new file mode 100644
index 0000000..8095e06
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/vo/PaginationPrefsVO.java
@@ -0,0 +1,20 @@
+package cn.rosercode.finance.vo;
+
+import java.util.List;
+
+import lombok.Data;
+
+/**
+ * 表格分页用户偏好视图对象,包含每页条数候选列表与默认每页条数。
+ *
+ * @date 2026-09-23 11:05:25
+ */
+@Data
+public class PaginationPrefsVO {
+
+ /** 每页条数候选列表,升序去重 */
+ private List options;
+
+ /** 默认每页条数,必为候选列表中的值 */
+ private Integer defaultSize;
+}