feat: 用户级偏好配置与外观设置,枚举统一抽取与账单类型后端下发
- 新增用户偏好表 t_user_preference 与进程内缓存,账单来源统计默认值由全局改为按用户存储(/api/setting) - 新增外观设置:不计入统计记录支持删除线与灰色标签两种展示样式 - 抽取 ChannelType / TransactionType / StatisticsScope 枚举,替换各处硬编码的渠道、类型与统计口径 - BillType 内聚各来源扩展名,新增 /transaction/import/types 接口,前端改为按后端下发渲染 - 仪表盘趋势图支持点击柱子跳转收支记录并带入筛选条件 - 布局内容区按路由记忆滚动位置;已登录访问登录/注册页自动跳转
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user