feat: 用户级偏好配置与外观设置,枚举统一抽取与账单类型后端下发

- 新增用户偏好表 t_user_preference 与进程内缓存,账单来源统计默认值由全局改为按用户存储(/api/setting)
- 新增外观设置:不计入统计记录支持删除线与灰色标签两种展示样式
- 抽取 ChannelType / TransactionType / StatisticsScope 枚举,替换各处硬编码的渠道、类型与统计口径
- BillType 内聚各来源扩展名,新增 /transaction/import/types 接口,前端改为按后端下发渲染
- 仪表盘趋势图支持点击柱子跳转收支记录并带入筛选条件
- 布局内容区按路由记忆滚动位置;已登录访问登录/注册页自动跳转
This commit is contained in:
2026-09-21 10:51:15 +08:00
parent 2976b85967
commit 1f520f4376
46 changed files with 1565 additions and 284 deletions
@@ -3,10 +3,10 @@
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NSpace, NSwitch, NButton, NIcon, NText,
useMessage, NCard, NSpace, NSwitch, NButton, NIcon, NTag, NAlert,
} from 'naive-ui'
import { OptionsOutline, SaveOutline } from '@vicons/ionicons5'
import { getBillSourceIncludeInStats, updateBillSourceIncludeInStats } from '../api/app'
import { getBillSourceIncludeInStats, updateBillSourceIncludeInStats } from '../api/setting'
import { getChannelLabel } from '../utils/channel'
const message = useMessage()
@@ -43,6 +43,12 @@ async function handleSave() {
}
}
/** 点击整行切换该来源的「计入统计」默认值 */
function toggle(code: string) {
if (loading.value) return
defaults.value[code] = !defaults.value[code]
}
onMounted(loadDefaults)
</script>
@@ -55,17 +61,24 @@ onMounted(loadDefaults)
</NSpace>
</template>
<NText depth="3" style="font-size: 12px;">{{ t('billSourceStats.hint') }}</NText>
<NAlert type="info" :show-icon="true" style="margin-bottom: 16px;">
{{ t('billSourceStats.hint') }}
</NAlert>
<div class="source-list">
<div v-for="(value, code) in defaults" :key="code" class="source-row">
<NSpace align="center" :size="12">
<NSwitch v-model:value="defaults[code]" :disabled="loading" />
<span>{{ getChannelLabel(String(code)) }}</span>
<NText depth="3" style="font-size: 12px;">
<div
v-for="(value, code) in defaults"
:key="code"
class="source-row"
@click="toggle(String(code))"
>
<span class="source-name">{{ getChannelLabel(String(code)) }}</span>
<div class="source-action">
<NTag size="small" :bordered="false" :type="value ? 'success' : 'default'">
{{ value ? t('billSourceStats.included') : t('billSourceStats.excluded') }}
</NText>
</NSpace>
</NTag>
<NSwitch v-model:value="defaults[code]" :disabled="loading" @click.stop />
</div>
</div>
</div>
@@ -82,9 +95,32 @@ onMounted(loadDefaults)
<style scoped>
.source-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
}
.source-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border: 1px solid #efeff5;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
}
.source-row:hover {
border-color: #d9d9e3;
background: #fafafc;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.source-name {
font-weight: 500;
color: #333;
}
.source-action {
display: flex;
align-items: center;
gap: 12px;
}
</style>
@@ -0,0 +1,190 @@
<script setup lang="ts">
// 外观设置面板:配置「不计入统计」记录在收支记录列表中的展示样式
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NSpace, NButton, NIcon, NText, NRadioGroup, NRadio,
} from 'naive-ui'
import { ColorPaletteOutline, SaveOutline } from '@vicons/ionicons5'
import { getExcludedDisplayStyle, updateExcludedDisplayStyle } from '../api/setting'
const message = useMessage()
const { t } = useI18n()
/** 当前展示样式:strikethrough-删除线(默认)、muted-灰色标签 */
const style = ref<string>('strikethrough')
const loading = ref(false)
const saving = ref(false)
/** 展示样式选项(随语言切换响应式更新) */
const styleOptions = computed(() => [
{ label: t('display.strikethrough'), value: 'strikethrough' },
{ label: t('display.muted'), value: 'muted' },
])
/** 加载当前展示样式并回显 */
async function loadStyle() {
loading.value = true
try {
const res: any = await getExcludedDisplayStyle()
style.value = res.data || 'strikethrough'
} catch (e: any) {
message.error(e.message || t('display.loadFailed'))
} finally {
loading.value = false
}
}
/** 保存展示样式 */
async function handleSave() {
saving.value = true
try {
const res: any = await updateExcludedDisplayStyle(style.value)
style.value = res.data || style.value
message.success(t('display.saveSuccess'))
} catch (e: any) {
message.error(e.message || t('display.saveFailed'))
} finally {
saving.value = false
}
}
onMounted(loadStyle)
</script>
<template>
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="ColorPaletteOutline" />
<span>{{ t('settings.displaySection') }}</span>
</NSpace>
</template>
<NText depth="3" style="font-size: 12px;">{{ t('display.hint') }}</NText>
<div class="style-row">
<div class="filter-label">{{ t('display.label') }}</div>
<NRadioGroup v-model:value="style" :disabled="loading">
<NRadio v-for="option in styleOptions" :key="option.value" :value="option.value">
{{ option.label }}
</NRadio>
</NRadioGroup>
</div>
<!-- 预览:按当前选择实时展示「不计入统计」记录的样式 -->
<div class="preview">
<div class="filter-label">{{ t('display.previewLabel') }}</div>
<div class="preview-table">
<div class="preview-head">
<span class="cell cell-type">{{ t('transaction.column.type') }}</span>
<span class="cell cell-category">{{ t('transaction.column.category') }}</span>
<span class="cell cell-amount">{{ t('transaction.column.amount') }}</span>
<span class="cell cell-remark">{{ t('transaction.column.remark') }}</span>
</div>
<div class="preview-row" :class="{ 'is-strikethrough': style === 'strikethrough' }">
<span class="cell cell-type">
<span v-if="style === 'muted'" class="tag tag-muted">{{ t('transaction.notIncluded') }}</span>
<span v-else class="tag tag-expense">{{ t('common.types.expense') }}</span>
</span>
<span class="cell cell-category">{{ t('display.previewCategory') }}</span>
<span class="cell cell-amount" :class="style === 'muted' ? 'amount-muted' : 'amount-expense'">
{{ style === 'muted' ? '88.00' : '-88.00' }}
</span>
<span class="cell cell-remark">{{ t('display.previewRemark') }}</span>
</div>
</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>
</template>
<style scoped>
.style-row {
margin-top: 12px;
}
.filter-label {
margin-bottom: 8px;
color: #999;
font-size: 12px;
}
.preview {
margin-top: 16px;
}
.preview-table {
border: 1px solid #efeff5;
border-radius: 8px;
overflow: hidden;
}
.preview-head,
.preview-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
}
.preview-head {
background: #fafafc;
color: #999;
font-size: 12px;
}
.preview-row {
font-size: 13px;
}
.cell {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-type {
width: 80px;
flex: none;
}
.cell-category {
width: 100px;
flex: none;
}
.cell-amount {
width: 100px;
flex: none;
font-weight: bold;
}
.cell-remark {
flex: 1;
}
.tag {
display: inline-block;
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
line-height: 20px;
}
.tag-expense {
background: #fdecef;
color: #d03050;
}
.tag-muted {
background: #f2f2f5;
color: #999;
}
.amount-expense {
color: #d03050;
}
.amount-muted {
color: #999;
}
/* 删除线样式:整行划线,类型与金额列保持不划线(与收支记录列表一致) */
.preview-row.is-strikethrough .cell-category,
.preview-row.is-strikethrough .cell-remark {
text-decoration: line-through;
}
</style>