feat: 表格分页支持统一配置并记忆各表格每页条数

- 后端新增聚合用户偏好 paginationPrefs(每页条数候选列表与默认每页条数),提供 GET/PUT /api/setting/pagination-prefs,默认候选 [10,20,50,100]、默认每页 20,非法值自动过滤并回退
- 后端新增 /category/page、/tag/page、/channel/page、/email-bill/configs/page、/email-bill/sender-rules/page 分页接口,原全量接口保持不变以免影响下拉、筛选、AI 与导出
- 前端新增共享 composables/usePageSizeOptions.ts 与 useTablePageSize.ts,所有表格统一取用候选列表并记住各自上次选择的每页条数,优先级为表格记忆 > 全局默认 > 20
- 各表格上次选择以聚合偏好 finance.pagination.tablePageSizes 存于本机 localStorage,收支记录页旧的 filterPrefs.pageSize 自动迁移
- 设置页外观设置新增「表格每页条数选项」分组,可增删候选值并配置默认每页条数,保存后全局即时生效
- 路由守卫在已登录时预加载分页偏好,保证表格页首次渲染即使用配置值
- 前端分类、标签、渠道、邮箱配置、发件人规则表格接入后端分页
- 补充中文、英文、日文三语文案
This commit is contained in:
2026-09-23 11:14:54 +08:00
parent 7172f6aa44
commit e0fa1abec5
39 changed files with 1042 additions and 62 deletions
+5
View File
@@ -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)
+5
View File
@@ -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 })
+10
View File
@@ -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)
+10
View File
@@ -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)
}
+5
View File
@@ -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)
+85 -3
View File
@@ -1,12 +1,16 @@
<script setup lang="ts">
// 外观设置面板:配置「不计入统计」记录在收支记录列表中的展示样式,以及是否显示已隐藏备注的记录
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NSpace, NButton, NIcon, NText, NRadioGroup, NRadio, NSwitch,
useMessage, NCard, NSpace, NButton, NIcon, NText, NRadioGroup, NRadio, NSwitch, NDynamicTags, NSelect,
} from 'naive-ui'
import { ColorPaletteOutline, SaveOutline } from '@vicons/ionicons5'
import { getExcludedDisplayStyle, updateExcludedDisplayStyle, getShowHiddenData, updateShowHiddenData } from '../api/setting'
import {
getExcludedDisplayStyle, updateExcludedDisplayStyle, getShowHiddenData, updateShowHiddenData,
getPaginationPrefs, updatePaginationPrefs,
} from '../api/setting'
import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE_OPTIONS, loadPageSizeOptions } from '../composables/usePageSizeOptions'
const message = useMessage()
const { t } = useI18n()
@@ -21,6 +25,29 @@ const showHiddenData = ref(false)
const loadingHidden = ref(false)
const savingHidden = ref(false)
/** 表格分页每页条数候选列表(字符串标签形式,保存时解析为数字) */
const pageSizeTags = ref<string[]>([])
/** 默认每页条数 */
const pageSizeDefault = ref<number>(DEFAULT_PAGE_SIZE)
const savingPageSizes = ref(false)
/** 候选列表解析为合法正整数(升序去重),用于默认值下拉 */
const parsedPageSizes = computed(() =>
Array.from(
new Set(pageSizeTags.value.map((v) => Number(String(v).trim())).filter((v) => Number.isInteger(v) && v > 0)),
).sort((a, b) => a - b),
)
/** 默认每页条数下拉选项(来自候选列表) */
const defaultSizeOptions = computed(() => parsedPageSizes.value.map((v) => ({ label: String(v), value: v })))
// 候选列表变更后,若默认值不在其中则回退为首项
watch(parsedPageSizes, (list) => {
if (list.length && !list.includes(pageSizeDefault.value)) {
pageSizeDefault.value = list[0]
}
})
/** 展示样式选项(随语言切换响应式更新) */
const styleOptions = computed(() => [
{ label: t('display.strikethrough'), value: 'strikethrough' },
@@ -85,9 +112,49 @@ async function handleHiddenChange(value: string | number | boolean) {
}
}
/** 应用后端返回的分页偏好到表单 */
function applyPaginationPrefs(data: any, fallbackOptions: number[], fallbackDefault: number) {
const list: number[] = Array.isArray(data?.options) ? data.options : fallbackOptions
pageSizeTags.value = list.map((v) => String(v))
const def = Number(data?.defaultSize)
pageSizeDefault.value = list.includes(def) ? def : (list[0] ?? fallbackDefault)
}
/** 加载表格分页偏好,失败时回退默认值 */
async function loadPageSizes() {
try {
const res: any = await getPaginationPrefs()
applyPaginationPrefs(res?.data, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PAGE_SIZE)
} catch (e: any) {
applyPaginationPrefs(null, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PAGE_SIZE)
}
}
/** 保存表格分页偏好(候选列表 + 默认每页条数),成功后刷新全局共享配置 */
async function handleSavePageSizes() {
const options = parsedPageSizes.value
if (!options.length) {
message.error(t('display.pageSizeEmpty'))
return
}
const defaultSize = options.includes(pageSizeDefault.value) ? pageSizeDefault.value : options[0]
savingPageSizes.value = true
try {
const res: any = await updatePaginationPrefs({ options, defaultSize })
applyPaginationPrefs(res?.data, options, defaultSize)
await loadPageSizeOptions(true)
message.success(t('display.pageSizeSaved'))
} catch (e: any) {
message.error(e.message || t('display.pageSizeFailed'))
} finally {
savingPageSizes.value = false
}
}
onMounted(() => {
loadStyle()
loadHiddenData()
loadPageSizes()
})
</script>
@@ -147,6 +214,21 @@ onMounted(() => {
</NSpace>
</div>
<div class="config-group">
<div class="group-title">{{ t('display.pageSizeLabel') }}</div>
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 8px;">{{ t('display.pageSizeHint') }}</NText>
<NSpace align="center" :size="12">
<NDynamicTags v-model:value="pageSizeTags" :max="10" style="max-width: 360px;" />
</NSpace>
<NSpace align="center" :size="12" style="margin-top: 8px;">
<NText depth="3" style="font-size: 12px;">{{ t('display.defaultPageSizeLabel') }}</NText>
<NSelect v-model:value="pageSizeDefault" :options="defaultSizeOptions" style="width: 120px;" />
<NButton size="small" :loading="savingPageSizes" @click="handleSavePageSizes">
{{ t('display.pageSizeSave') }}
</NButton>
</NSpace>
</div>
<NSpace justify="end" style="margin-top: 16px; width: 100%;">
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon>
@@ -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<number | null>(null)
const editorType = ref<TransactionRelationType>('PARENT_CHILD')
@@ -52,7 +57,7 @@ const showPicker = ref(false)
const pickerRows = ref<RelationMemberDraft[]>([])
const pickerChecked = ref<number[]>([])
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<DataTableColumns<RelationMemberDraft>>(() => [
<NSpace justify="space-between" align="center" style="margin-top: 12px;">
<NPagination
v-model:page="pickerPage"
:page-size="pickerSize"
v-model:page-size="pickerSize"
:item-count="pickerTotal"
:page-sizes="pageSizeOptions"
show-size-picker
@update:page="loadPicker"
@update:page-size="handlePickerPageSizeChange"
/>
<NSpace>
<NButton @click="showPicker = false">{{ t('common.cancel') }}</NButton>
@@ -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<number[]>([...DEFAULT_PAGE_SIZE_OPTIONS])
const defaultPageSize = ref<number>(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<number[]> {
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 }
}
@@ -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<number> {
const { pageSizeOptions, defaultPageSize } = usePageSizeOptions()
const saved = readTablePageSize(tableKey)
const initial = saved !== null && pageSizeOptions.value.includes(saved) ? saved : defaultPageSize.value
const size = ref<number>(initial)
watch(size, (next) => writeTablePageSize(tableKey, next))
return size
}
+7
View File
@@ -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ページ件数オプションの保存に失敗しました' },
},
// 注册设置
+8 -2
View File
@@ -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
+2
View File
@@ -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 查询历史
+34
View File
@@ -0,0 +1,34 @@
// 各表格每页大小偏好:以聚合 JSON 记录每个表格上次选择的每页条数,本机记忆
import { readJson, writeJson } from './storage'
import { STORAGE_KEYS } from './storageKeys'
/** 表格键到每页大小的映射 */
export type TablePageSizes = Record<string, number>
/** 读取全部表格的每页大小偏好,仅保留合法正整数 */
export function readTablePageSizes(): TablePageSizes {
const raw = readJson<Record<string, unknown>>(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)
}
+105 -13
View File
@@ -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<CategoryItem[]>([]) // 全部分类列表
const expenseList = ref<CategoryItem[]>([]) // 支出分类列表(当前页)
const incomeList = ref<CategoryItem[]>([]) // 收入分类列表(当前页)
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<number | null>(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<CategoryItem> {
return [
@@ -85,19 +114,64 @@ function createColumns(): DataTableColumns<CategoryItem> {
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(() => {
<!-- 支出分类列表 -->
<h3>{{ t('category.expenseSection') }}</h3>
<NDataTable :columns="columns" :data="expenseList" :loading="loading" :bordered="false" />
<NDataTable
remote
:columns="columns"
:data="expenseList"
:loading="loading"
:bordered="false"
:pagination="expensePagination"
@update:page="handleExpensePageChange"
@update:page-size="handleExpensePageSizeChange"
/>
<!-- 收入分类列表 -->
<h3 style="margin-top: 24px;">{{ t('category.incomeSection') }}</h3>
<NDataTable :columns="columns" :data="incomeList" :loading="loading" :bordered="false" />
<NDataTable
remote
:columns="columns"
:data="incomeList"
:loading="loading"
:bordered="false"
:pagination="incomePagination"
@update:page="handleIncomePageChange"
@update:page-size="handleIncomePageSizeChange"
/>
<!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('category.editTitle') : t('category.addTitle')" style="width: 450px;">
+47 -5
View File
@@ -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<ChannelItem[]>([]) // 自定义渠道列表
const channels = ref<ChannelItem[]>([]) // 自定义渠道列表(当前页)
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<number | null>(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)
<NAlert type="info" :show-icon="true" style="margin-bottom: 16px;">{{ t('channel.hint') }}</NAlert>
<!-- 自定义渠道数据表格 -->
<NDataTable :columns="columns" :data="channels" :loading="loading" :bordered="false" />
<NDataTable
remote
:columns="columns"
:data="channels"
:loading="loading"
:bordered="false"
:pagination="pagination"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
<!-- 新增/改名弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('channel.editTitle') : t('channel.addTitle')" style="width: 450px;">
+91 -10
View File
@@ -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<EmailBillConfig[]>([])
const configLoading = ref(false)
const configPage = ref(1)
const configSize = useTablePageSize('emailConfig')
const configTotal = ref(0)
const configModalVisible = ref(false)
const configEditingId = ref<number | null>(null)
const configForm = ref<EmailBillConfigPayload>(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<string[]>([])
const checkRunning = ref(false)
@@ -207,8 +224,9 @@ const configColumns = computed<DataTableColumns<EmailBillConfig>>(() => [
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<EmailBillSenderRule[]>([])
const ruleLoading = ref(false)
const rulePage = ref(1)
const ruleSize = useTablePageSize('emailRule')
const ruleTotal = ref(0)
const ruleModalVisible = ref(false)
const ruleEditingId = ref<number | null>(null)
const ruleForm = ref<Omit<EmailBillSenderRulePayload, 'senderPatterns'> & { 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<DataTableColumns<EmailBillSenderRule>>(() => [
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<EmailBillMessage[]>([])
const messageLoading = ref(false)
const messageStatus = ref<string | null>('PULLED')
const messagePage = ref(1)
const messageSize = ref(20)
const messageSize = useTablePageSize('emailMessage')
const messageTotal = ref(0)
const messageColumns = computed<DataTableColumns<EmailBillMessage>>(() => [
@@ -704,7 +761,18 @@ onUnmounted(() => {
<NSpace justify="end">
<NButton type="primary" @click="openConfigModal()">{{ t('emailBill.common.add') }}</NButton>
</NSpace>
<NDataTable :columns="configColumns" :data="configs" :loading="configLoading" :row-key="(r: EmailBillConfig) => r.id" size="small" :bordered="false" />
<NDataTable
remote
:columns="configColumns"
:data="configs"
:loading="configLoading"
:row-key="(r: EmailBillConfig) => r.id"
size="small"
:bordered="false"
:pagination="configPagination"
@update:page="handleConfigPageChange"
@update:page-size="handleConfigPageSizeChange"
/>
<NCard
v-if="checkRunning || checkLogs.length"
size="small"
@@ -727,7 +795,18 @@ onUnmounted(() => {
<NSpace justify="end">
<NButton type="primary" @click="openRuleModal()">{{ t('emailBill.common.add') }}</NButton>
</NSpace>
<NDataTable :columns="ruleColumns" :data="senderRules" :loading="ruleLoading" :row-key="(r: EmailBillSenderRule) => r.id" size="small" :bordered="false" />
<NDataTable
remote
:columns="ruleColumns"
:data="senderRules"
:loading="ruleLoading"
:row-key="(r: EmailBillSenderRule) => r.id"
size="small"
:bordered="false"
:pagination="rulePagination"
@update:page="handleRulePageChange"
@update:page-size="handleRulePageSizeChange"
/>
</NSpace>
</NTabPane>
@@ -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() },
}"
/>
</NSpace>
+9 -4
View File
@@ -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<string>((route.params.tab as string) || 'http')
@@ -43,7 +48,7 @@ const retentionOptions = computed(() => [
const httpRecords = ref<HttpLog[]>([])
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<string | null>(null)
@@ -53,7 +58,7 @@ const httpStatus = ref<number | null>(null)
const loginRecords = ref<LoginLog[]>([])
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"
+9 -4
View File
@@ -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<TagOption[]>([])
// 规则列表分页与筛选
const page = ref(1)
const pageSize = ref(20)
const pageSize = useTablePageSize('rule')
const total = ref(0)
const searchKeyword = ref('')
const filterEnabled = ref<string | null>(null) // 'ON' | 'OFF' | null
@@ -75,7 +80,7 @@ const aiCandidateRows = ref<CandidateRow[]>([])
const aiCandidateLoading = ref(false)
const checkedCandidateKeys = ref<number[]>([])
const aiCandidatePage = ref(1)
const aiCandidatePageSize = ref(20)
const aiCandidatePageSize = useTablePageSize('ruleAiCandidate')
const aiCandidateTotal = ref(0)
const aiCandidateSearch = ref('')
const aiCandidateType = ref<string | null>(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,
}))
+47 -5
View File
@@ -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<TagItem[]>([]) // 标签列表
const tags = ref<TagItem[]>([]) // 标签列表(当前页)
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<number | null>(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(() => {
</NSpace>
<!-- 标签数据表格 -->
<NDataTable :columns="columns" :data="tags" :loading="loading" :bordered="false" />
<NDataTable
remote
:columns="columns"
:data="tags"
:loading="loading"
:bordered="false"
:pagination="pagination"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
<!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('tag.editTitle') : t('tag.addTitle')" style="width: 450px;">
+13 -6
View File
@@ -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<TransactionItem[]>([]) // 交易记录列表
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"
@@ -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<PageResult<CategoryVO>> 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));
}
/**
* 创建分类
*
@@ -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<PageResult<ChannelVO>> page(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return Result.success(channelService.page(UserContext.getUserId(), page, size));
}
/**
* 新建自定义渠道
*
@@ -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<PageResult<EmailBillConfigVO>> 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<PageResult<EmailBillSenderRuleVO>> 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));
}
/**
* 创建发件人规则
*
@@ -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<PageResult<TagVO>> 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));
}
/**
* 创建标签
*
@@ -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;
/**
* 用户设置控制器
*
* <p>提供按当前登录用户存储的偏好配置读写接口,包含账单来源统计默认值与
* 「不计入统计」记录的展示样式。</p>
* <p>提供按当前登录用户存储的偏好配置读写接口,包含账单来源统计默认值、
* 「不计入统计」记录的展示样式与表格分页偏好。</p>
*
* @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<PaginationPrefsVO> getPaginationPrefs() {
return Result.success(userPreferenceService.getPaginationPrefs(UserContext.getUserId()));
}
/**
* 更新当前用户配置的表格分页偏好。
*
* @param request 分页偏好更新请求
* @return 更新后的分页偏好,空或非法时回退系统默认
*/
@PutMapping("/pagination-prefs")
@Operation(summary = "更新分页偏好", description = "设置当前用户所有表格分页的每页条数候选列表与默认每页条数")
public Result<PaginationPrefsVO> 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));
}
}
@@ -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<Integer> options;
/** 默认每页条数 */
private Integer defaultSize;
}
@@ -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<CategoryVO> listByType(Long userId, String type);
/**
* 分页查询用户的分类,可按类型筛选。
*
* @param userId 用户ID
* @param type 分类类型(如 INCOME、EXPENSE),为空时查询全部
* @param page 页码,从 1 开始
* @param size 每页大小
* @return 分页结果
*/
PageResult<CategoryVO> page(Long userId, String type, int page, int size);
/**
* 创建新分类
*
@@ -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<ChannelVO> list(Long userId);
/**
* 分页查询当前用户的自定义渠道列表(含各自使用账单数)。
*
* @param userId 用户ID
* @param page 页码,从 1 开始
* @param size 每页大小
* @return 分页结果
*/
PageResult<ChannelVO> page(Long userId, int page, int size);
/**
* 新建自定义渠道。
*
@@ -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<EmailBillConfigVO> list(Long userId);
/**
* 分页查询当前用户的邮箱配置。
*
* @param userId 当前用户ID
* @param page 页码,从 1 开始
* @param size 每页大小
* @return 分页结果
*/
PageResult<EmailBillConfigVO> page(Long userId, int page, int size);
/**
* 新增邮箱配置。
*
@@ -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<EmailBillSenderRuleVO> list(Long userId);
/**
* 分页查询当前用户的发件人规则。
*
* @param userId 当前用户ID
* @param page 页码,从 1 开始
* @param size 每页大小
* @return 分页结果,按优先级升序
*/
PageResult<EmailBillSenderRuleVO> page(Long userId, int page, int size);
/**
* 新增发件人规则。
*
@@ -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<TagVO> list(Long userId);
/**
* 分页查询用户的标签列表。
*
* @param userId 用户ID
* @param page 页码,从 1 开始
* @param size 每页大小
* @return 分页结果
*/
PageResult<TagVO> page(Long userId, int page, int size);
/**
* 创建新标签
*
@@ -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);
}
@@ -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<CategoryVO> page(Long userId, String type, int page, int size) {
LambdaQueryWrapper<Category> wrapper = new LambdaQueryWrapper<Category>()
.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<Category> result = categoryMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
List<CategoryVO> records = result.getRecords().stream()
.map(voConverter::toCategoryVO)
.collect(Collectors.toList());
return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());
}
/**
* {@inheritDoc}
*
@@ -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,14 +71,46 @@ public class ChannelServiceImpl implements ChannelService {
.orderByAsc(Channel::getId));
List<ChannelVO> result = new ArrayList<>();
for (Channel channel : channels) {
result.add(toChannelVO(userId, channel));
}
return result;
}
/**
* {@inheritDoc}
*
* 按排序字段和ID升序分页,并对当前页渠道逐条统计使用账单数。
*/
@Override
public PageResult<ChannelVO> page(Long userId, int page, int size) {
LambdaQueryWrapper<Channel> wrapper = new LambdaQueryWrapper<Channel>()
.eq(Channel::getUserId, userId)
.orderByAsc(Channel::getSortOrder)
.orderByAsc(Channel::getId);
int pageNo = page < 1 ? 1 : page;
int pageSize = size < 1 ? 20 : size;
Page<Channel> result = channelMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
List<ChannelVO> 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()));
result.add(vo);
}
return result;
return vo;
}
/**
@@ -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<EmailBillConfigVO> page(Long userId, int page, int size) {
LambdaQueryWrapper<EmailBillConfig> wrapper = new LambdaQueryWrapper<EmailBillConfig>()
.eq(EmailBillConfig::getUserId, userId)
.orderByAsc(EmailBillConfig::getId);
int pageNo = page < 1 ? 1 : page;
int pageSize = size < 1 ? 20 : size;
Page<EmailBillConfig> result = configMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
List<EmailBillConfigVO> records = new ArrayList<>();
for (EmailBillConfig config : result.getRecords()) {
records.add(toVO(config));
}
return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());
}
/**
* {@inheritDoc}
*
@@ -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<EmailBillSenderRuleVO> page(Long userId, int page, int size) {
LambdaQueryWrapper<EmailBillSenderRule> wrapper = new LambdaQueryWrapper<EmailBillSenderRule>()
.eq(EmailBillSenderRule::getUserId, userId)
.orderByAsc(EmailBillSenderRule::getSortOrder)
.orderByAsc(EmailBillSenderRule::getId);
int pageNo = page < 1 ? 1 : page;
int pageSize = size < 1 ? 20 : size;
Page<EmailBillSenderRule> result = senderRuleMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
List<EmailBillSenderRuleVO> records = new ArrayList<>();
for (EmailBillSenderRule rule : result.getRecords()) {
records.add(toVO(rule));
}
return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());
}
/**
* {@inheritDoc}
*/
@@ -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<TagVO> page(Long userId, int page, int size) {
LambdaQueryWrapper<Tag> wrapper = new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId)
.orderByAsc(Tag::getSortOrder)
.orderByAsc(Tag::getId);
int pageNo = page < 1 ? 1 : page;
int pageSize = size < 1 ? 20 : size;
Page<Tag> result = tagMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
List<TagVO> records = result.getRecords().stream()
.map(voConverter::toTagVO)
.collect(Collectors.toList());
return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());
}
/**
* {@inheritDoc}
*
@@ -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<Integer> 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<String> 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<Integer> options = prefs != null ? prefs.getOptions() : null;
List<Integer> 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 字符串。
*
@@ -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<Integer> options;
/** 默认每页条数,必为候选列表中的值 */
private Integer defaultSize;
}