feat: 外观设置分页与展示样式改为即时保存

- 分页每页条数候选与默认值变更后防抖 500ms 自动保存,移除分组内独立「保存」按钮
- 「不计入统计」展示样式切换即保存,失败回滚到上一值,移除面板底部「保存」按钮
- 自动保存增加初始化与回填保护、过期响应丢弃,候选删空时不提交以免覆盖后端值
- 更新中英日三语提示文案,清理不再使用的保存相关文案
This commit is contained in:
2026-09-24 08:51:13 +08:00
parent 24074aeea2
commit 5673f6fca2
2 changed files with 51 additions and 41 deletions
+49 -35
View File
@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
// 外观设置面板:配置「不计入统计」记录在收支记录列表中的展示样式,以及是否显示已隐藏备注的记录 // 外观设置面板:配置「不计入统计」记录在收支记录列表中的展示样式,以及是否显示已隐藏备注的记录
import { ref, onMounted, computed, watch } from 'vue' import { ref, onMounted, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { import {
useMessage, NCard, NSpace, NButton, NIcon, NText, NRadioGroup, NRadio, NSwitch, NDynamicTags, NSelect, useMessage, NCard, NSpace, NIcon, NText, NRadioGroup, NRadio, NSwitch, NDynamicTags, NSelect,
} from 'naive-ui' } from 'naive-ui'
import { ColorPaletteOutline, SaveOutline } from '@vicons/ionicons5' import { ColorPaletteOutline } from '@vicons/ionicons5'
import { import {
getExcludedDisplayStyle, updateExcludedDisplayStyle, getShowHiddenData, updateShowHiddenData, getExcludedDisplayStyle, updateExcludedDisplayStyle, getShowHiddenData, updateShowHiddenData,
getPaginationPrefs, updatePaginationPrefs, getPaginationPrefs, updatePaginationPrefs,
@@ -18,18 +18,25 @@ const { t } = useI18n()
/** 当前展示样式:strikethrough-删除线(默认)、muted-灰色标签 */ /** 当前展示样式:strikethrough-删除线(默认)、muted-灰色标签 */
const style = ref<string>('strikethrough') const style = ref<string>('strikethrough')
const loading = ref(false) const loading = ref(false)
const saving = ref(false)
/** 是否显示已隐藏备注的记录,默认不显示 */ /** 是否显示已隐藏备注的记录,默认不显示 */
const showHiddenData = ref(false) const showHiddenData = ref(false)
const loadingHidden = ref(false) const loadingHidden = ref(false)
const savingHidden = ref(false) const savingHidden = ref(false)
/** 表格分页每页条数候选列表(字符串标签形式,保存时解析为数字) */ /** 表格分页每页条数候选列表(字符串标签形式,提交时解析为数字) */
const pageSizeTags = ref<string[]>([]) const pageSizeTags = ref<string[]>([])
/** 默认每页条数 */ /** 默认每页条数 */
const pageSizeDefault = ref<number>(DEFAULT_PAGE_SIZE) const pageSizeDefault = ref<number>(DEFAULT_PAGE_SIZE)
const savingPageSizes = ref(false)
/** 初次加载完成后才允许自动保存,避免加载时误提交 */
let paginationReady = false
/** 回填后端响应期间跳过自动保存,避免循环提交 */
let syncingFromServer = false
/** 自动保存防抖计时器 */
let pageSizeSaveTimer: ReturnType<typeof setTimeout> | null = null
/** 保存请求序号,用于丢弃过期响应 */
let pageSizeSaveSeq = 0
/** 候选列表解析为合法正整数(升序去重),用于默认值下拉 */ /** 候选列表解析为合法正整数(升序去重),用于默认值下拉 */
const parsedPageSizes = computed(() => const parsedPageSizes = computed(() =>
@@ -41,11 +48,13 @@ const parsedPageSizes = computed(() =>
/** 默认每页条数下拉选项(来自候选列表) */ /** 默认每页条数下拉选项(来自候选列表) */
const defaultSizeOptions = computed(() => parsedPageSizes.value.map((v) => ({ label: String(v), value: v }))) const defaultSizeOptions = computed(() => parsedPageSizes.value.map((v) => ({ label: String(v), value: v })))
// 候选列表变更后,若默认值不在其中则回退为首项 // 候选列表或默认值变更后:先校正失效的默认值,再防抖自动保存
watch(parsedPageSizes, (list) => { watch([parsedPageSizes, pageSizeDefault], ([list]) => {
if (syncingFromServer) return
if (list.length && !list.includes(pageSizeDefault.value)) { if (list.length && !list.includes(pageSizeDefault.value)) {
pageSizeDefault.value = list[0] pageSizeDefault.value = list[0]
} }
schedulePageSizeSave()
}) })
/** 展示样式选项(随语言切换响应式更新) */ /** 展示样式选项(随语言切换响应式更新) */
@@ -67,17 +76,17 @@ async function loadStyle() {
} }
} }
/** 保存展示样式 */ /** 切换展示样式并即时保存,失败时回滚到上一值 */
async function handleSave() { async function handleStyleChange(value: string | number) {
saving.value = true const next = String(value)
const previous = style.value
style.value = next
try { try {
const res: any = await updateExcludedDisplayStyle(style.value) const res: any = await updateExcludedDisplayStyle(next)
style.value = res.data || style.value style.value = res.data || next
message.success(t('display.saveSuccess'))
} catch (e: any) { } catch (e: any) {
style.value = previous
message.error(e.message || t('display.saveFailed')) message.error(e.message || t('display.saveFailed'))
} finally {
saving.value = false
} }
} }
@@ -112,12 +121,16 @@ async function handleHiddenChange(value: string | number | boolean) {
} }
} }
/** 应用后端返回的分页偏好到表单 */ /** 应用后端返回的分页偏好到表单,回填期间跳过自动保存,避免循环提交 */
function applyPaginationPrefs(data: any, fallbackOptions: number[], fallbackDefault: number) { function applyPaginationPrefs(data: any, fallbackOptions: number[], fallbackDefault: number) {
syncingFromServer = true
const list: number[] = Array.isArray(data?.options) ? data.options : fallbackOptions const list: number[] = Array.isArray(data?.options) ? data.options : fallbackOptions
pageSizeTags.value = list.map((v) => String(v)) pageSizeTags.value = list.map((v) => String(v))
const def = Number(data?.defaultSize) const def = Number(data?.defaultSize)
pageSizeDefault.value = list.includes(def) ? def : (list[0] ?? fallbackDefault) pageSizeDefault.value = list.includes(def) ? def : (list[0] ?? fallbackDefault)
void nextTick(() => {
syncingFromServer = false
})
} }
/** 加载表格分页偏好,失败时回退默认值 */ /** 加载表格分页偏好,失败时回退默认值 */
@@ -127,27 +140,40 @@ async function loadPageSizes() {
applyPaginationPrefs(res?.data, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PAGE_SIZE) applyPaginationPrefs(res?.data, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PAGE_SIZE)
} catch (e: any) { } catch (e: any) {
applyPaginationPrefs(null, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PAGE_SIZE) applyPaginationPrefs(null, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PAGE_SIZE)
} finally {
paginationReady = true
} }
} }
/** 保存表格分页偏好(候选列表 + 默认每页条数),成功后刷新全局共享配置 */ /** 防抖调度分页偏好自动保存,加载完成前或回填响应期间不触发 */
function schedulePageSizeSave() {
if (!paginationReady || syncingFromServer) return
if (pageSizeSaveTimer) clearTimeout(pageSizeSaveTimer)
pageSizeSaveTimer = setTimeout(() => {
pageSizeSaveTimer = null
void handleSavePageSizes()
}, 500)
}
/** 自动保存分页偏好(候选列表 + 默认每页条数),成功后刷新全局共享配置 */
async function handleSavePageSizes() { async function handleSavePageSizes() {
const options = parsedPageSizes.value const options = parsedPageSizes.value
// 候选被删空时保持后端原值,避免回退系统默认覆盖用户配置
if (!options.length) { if (!options.length) {
message.error(t('display.pageSizeEmpty')) message.error(t('display.pageSizeEmpty'))
return return
} }
const defaultSize = options.includes(pageSizeDefault.value) ? pageSizeDefault.value : options[0] const defaultSize = options.includes(pageSizeDefault.value) ? pageSizeDefault.value : options[0]
savingPageSizes.value = true const seq = ++pageSizeSaveSeq
try { try {
const res: any = await updatePaginationPrefs({ options, defaultSize }) const res: any = await updatePaginationPrefs({ options, defaultSize })
// 已有更新的保存请求,丢弃本次过期响应
if (seq !== pageSizeSaveSeq) return
applyPaginationPrefs(res?.data, options, defaultSize) applyPaginationPrefs(res?.data, options, defaultSize)
await loadPageSizeOptions(true) await loadPageSizeOptions(true)
message.success(t('display.pageSizeSaved'))
} catch (e: any) { } catch (e: any) {
if (seq !== pageSizeSaveSeq) return
message.error(e.message || t('display.pageSizeFailed')) message.error(e.message || t('display.pageSizeFailed'))
} finally {
savingPageSizes.value = false
} }
} }
@@ -170,7 +196,7 @@ onMounted(() => {
<div class="config-group"> <div class="config-group">
<div class="group-title">{{ t('display.label') }}</div> <div class="group-title">{{ t('display.label') }}</div>
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 8px;">{{ t('display.hint') }}</NText> <NText depth="3" style="font-size: 12px; display: block; margin-bottom: 8px;">{{ t('display.hint') }}</NText>
<NRadioGroup v-model:value="style" :disabled="loading"> <NRadioGroup :value="style" :disabled="loading" @update:value="handleStyleChange">
<NRadio v-for="option in styleOptions" :key="option.value" :value="option.value"> <NRadio v-for="option in styleOptions" :key="option.value" :value="option.value">
{{ option.label }} {{ option.label }}
</NRadio> </NRadio>
@@ -223,20 +249,8 @@ onMounted(() => {
<NSpace align="center" :size="12" style="margin-top: 8px;"> <NSpace align="center" :size="12" style="margin-top: 8px;">
<NText depth="3" style="font-size: 12px;">{{ t('display.defaultPageSizeLabel') }}</NText> <NText depth="3" style="font-size: 12px;">{{ t('display.defaultPageSizeLabel') }}</NText>
<NSelect v-model:value="pageSizeDefault" :options="defaultSizeOptions" style="width: 120px;" /> <NSelect v-model:value="pageSizeDefault" :options="defaultSizeOptions" style="width: 120px;" />
<NButton size="small" :loading="savingPageSizes" @click="handleSavePageSizes">
{{ t('display.pageSizeSave') }}
</NButton>
</NSpace> </NSpace>
</div> </div>
<NSpace justify="end" style="margin-top: 16px; width: 100%;">
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('display.save') }}
</NButton>
</NSpace>
</NCard> </NCard>
</template> </template>
+2 -6
View File
@@ -181,15 +181,13 @@ export default {
// 外观设置 // 外观设置
display: { display: {
hint: { zh: '选择「不计入统计」的记录在收支记录列表中的展示方式', en: 'Choose how records excluded from stats are shown in the transaction list', ja: '集計対象外の記録を収支リストでどのように表示するかを選択します' }, hint: { zh: '选择「不计入统计」的记录在收支记录列表中的展示方式,修改后自动保存', en: 'Choose how records excluded from stats are shown in the transaction list; changes are saved automatically', ja: '集計対象外の記録を収支リストでどのように表示するかを選択します。変更は自動保存されます' },
label: { zh: '不计入统计的记录样式', en: 'Excluded record style', ja: '集計対象外の記録スタイル' }, label: { zh: '不计入统计的记录样式', en: 'Excluded record style', ja: '集計対象外の記録スタイル' },
strikethrough: { zh: '删除线(整行划线)', en: 'Strikethrough (whole row)', ja: '取り消し線(行全体)' }, strikethrough: { zh: '删除线(整行划线)', en: 'Strikethrough (whole row)', ja: '取り消し線(行全体)' },
muted: { zh: '灰色标签(类型显示“不计入”,金额置灰)', en: 'Gray label (type shows "Excluded", amount grayed out)', ja: 'グレー表示(種類は「対象外」、金額はグレー)' }, muted: { zh: '灰色标签(类型显示“不计入”,金额置灰)', en: 'Gray label (type shows "Excluded", amount grayed out)', ja: 'グレー表示(種類は「対象外」、金額はグレー)' },
previewLabel: { zh: '预览', en: 'Preview', ja: 'プレビュー' }, previewLabel: { zh: '预览', en: 'Preview', ja: 'プレビュー' },
previewCategory: { zh: '餐饮', en: 'Dining', ja: '食事' }, previewCategory: { zh: '餐饮', en: 'Dining', ja: '食事' },
previewRemark: { zh: '示例备注', en: 'Sample remark', ja: 'サンプル備考' }, previewRemark: { zh: '示例备注', en: 'Sample remark', ja: 'サンプル備考' },
save: { zh: '保存', en: 'Save', ja: '保存' },
saveSuccess: { zh: '外观设置已保存', en: 'Appearance settings saved', ja: '外観設定を保存しました' },
saveFailed: { zh: '保存外观设置失败', en: 'Failed to save appearance settings', ja: '外観設定の保存に失敗しました' }, saveFailed: { zh: '保存外观设置失败', en: 'Failed to save appearance settings', ja: '外観設定の保存に失敗しました' },
loadFailed: { zh: '加载外观设置失败', en: 'Failed to load appearance settings', ja: '外観設定の読み込みに失敗しました' }, loadFailed: { zh: '加载外观设置失败', en: 'Failed to load appearance settings', ja: '外観設定の読み込みに失敗しました' },
hiddenDataLabel: { zh: '显示已隐藏备注的记录', en: 'Show records with hidden remarks', ja: '備考を非表示にした記録を表示' }, hiddenDataLabel: { zh: '显示已隐藏备注的记录', en: 'Show records with hidden remarks', ja: '備考を非表示にした記録を表示' },
@@ -197,11 +195,9 @@ export default {
hiddenDataSaved: { zh: '已更新隐藏备注记录的显示设置', en: 'Hidden remark record display updated', ja: '非表示備考の記録表示を更新しました' }, hiddenDataSaved: { zh: '已更新隐藏备注记录的显示设置', en: 'Hidden remark record display updated', ja: '非表示備考の記録表示を更新しました' },
hiddenDataFailed: { zh: '更新隐藏备注记录的显示设置失败', en: 'Failed to update hidden remark record display', ja: '非表示備考の記録表示の更新に失敗しました' }, hiddenDataFailed: { zh: '更新隐藏备注记录的显示设置失败', en: 'Failed to update hidden remark record display', ja: '非表示備考の記録表示の更新に失敗しました' },
pageSizeLabel: { zh: '表格每页条数选项', en: 'Table page size options', ja: 'テーブルの1ページ件数オプション' }, 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 を使用' }, pageSizeHint: { zh: '所有表格分页共用的每页条数候选列表,修改后自动保存;未配置时使用默认 10 / 20 / 50 / 100', en: 'Page size choices shared by all table pagination; changes are saved automatically. Defaults to 10 / 20 / 50 / 100 when unset', ja: 'すべてのテーブルで共通の1ページ件数候補。変更は自動保存されます。未設定時は 10 / 20 / 50 / 100 を使用' },
defaultPageSizeLabel: { zh: '默认每页条数', en: 'Default page size', ja: '既定の1ページ件数' }, 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つ以上入力してください' }, 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ページ件数オプションの保存に失敗しました' }, pageSizeFailed: { zh: '保存每页条数选项失败', en: 'Failed to save page size options', ja: '1ページ件数オプションの保存に失敗しました' },
}, },