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
-12
View File
@@ -1,12 +0,0 @@
// 应用级配置接口:按账单来源的「计入统计」默认值
import request from './request'
/** 查询按账单来源的「计入统计」默认值(key 为账单来源编码) */
export function getBillSourceIncludeInStats() {
return request.get('/app/bill-source-include-in-stats')
}
/** 更新按账单来源的「计入统计」默认值 */
export function updateBillSourceIncludeInStats(defaults: Record<string, boolean>) {
return request.put('/app/bill-source-include-in-stats', { defaults })
}
+22
View File
@@ -0,0 +1,22 @@
// 用户设置接口:当前登录用户的账单来源统计默认值与「不计入统计」展示样式
import request from './request'
/** 查询当前用户按账单来源的「计入统计」默认值(key 为账单来源编码) */
export function getBillSourceIncludeInStats() {
return request.get('/setting/bill-source-include-in-stats')
}
/** 更新当前用户按账单来源的「计入统计」默认值 */
export function updateBillSourceIncludeInStats(defaults: Record<string, boolean>) {
return request.put('/setting/bill-source-include-in-stats', { defaults })
}
/** 查询当前用户「不计入统计」记录的展示样式(strikethrough / muted) */
export function getExcludedDisplayStyle() {
return request.get('/setting/excluded-display-style')
}
/** 更新当前用户「不计入统计」记录的展示样式 */
export function updateExcludedDisplayStyle(style: string) {
return request.put('/setting/excluded-display-style', { style })
}
+13 -2
View File
@@ -77,8 +77,19 @@ export function toggleRemarkHidden(id: number, remarkHidden: boolean) {
return request.put(`/transaction/${id}/remark-hidden`, { remarkHidden })
}
/** 支持的账单类型,当前支持支付宝、微信、美团、招商银行与交通银行 */
export type BillType = 'ALIPAY' | 'WECHAT' | 'MEITUAN' | 'CMB' | 'BOCOM'
/** 支持的账单类型编码(由后端 /transaction/import/types 下发,此处为已知值提示) */
export type BillType = string
/** 账单类型选项(后端下发),extensions 为该来源允许的账单文件扩展名 */
export interface BillTypeOption {
code: string
extensions: string[]
}
/** 获取系统支持的账单类型及允许的文件扩展名 */
export function getBillTypes() {
return request.get('/transaction/import/types')
}
/** 账单解析结果行 */
export interface ParsedBillRow {
@@ -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>
+17
View File
@@ -137,6 +137,7 @@ export default {
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' },
billSourceStatsSection: { zh: '账单来源统计默认值', en: 'Bill Source Stats Defaults', ja: '請求元の集計既定値' },
displaySection: { zh: '外观设置', en: 'Appearance', ja: '外観設定' },
notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' },
},
@@ -151,6 +152,21 @@ export default {
saveFailed: { zh: '保存账单来源统计默认值失败', en: 'Failed to save bill source stats defaults', ja: '請求元の集計既定値の保存に失敗しました' },
},
// 外观设置
display: {
hint: { zh: '选择「不计入统计」的记录在收支记录列表中的展示方式', en: 'Choose how records excluded from stats are shown in the transaction list', ja: '集計対象外の記録を収支リストでどのように表示するかを選択します' },
label: { zh: '不计入统计的记录样式', en: 'Excluded record style', ja: '集計対象外の記録スタイル' },
strikethrough: { zh: '删除线(整行划线)', en: 'Strikethrough (whole row)', ja: '取り消し線(行全体)' },
muted: { zh: '灰色标签(类型显示“不计入”,金额置灰)', en: 'Gray label (type shows "Excluded", amount grayed out)', ja: 'グレー表示(種類は「対象外」、金額はグレー)' },
previewLabel: { zh: '预览', en: 'Preview', ja: 'プレビュー' },
previewCategory: { zh: '餐饮', en: 'Dining', 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: '外観設定の保存に失敗しました' },
loadFailed: { zh: '加载外观设置失败', en: 'Failed to load appearance settings', ja: '外観設定の読み込みに失敗しました' },
},
// 注册设置
registration: {
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
@@ -331,6 +347,7 @@ export default {
maxExpense: { zh: '最大单笔支出', en: 'Largest Expense', ja: '最大の支出' },
avgExpense: { zh: '平均每笔支出', en: 'Avg. Expense', ja: '平均支出' },
trend: { zh: '收支趋势', en: 'Income & Expense Trend', ja: '収支の推移' },
trendClickHint: { zh: '点击柱子查看对应明细', en: 'Click a bar to view matching records', ja: '棒をクリックすると該当明細を表示' },
expenseCategoryPie: { zh: '支出分类占比', en: 'Expense by Category', ja: '支出カテゴリの割合' },
incomeCategoryPie: { zh: '收入分类占比', en: 'Income by Category', ja: '収入カテゴリの割合' },
expenseChannelPie: { zh: '支出交易渠道占比', en: 'Expense by Channel', ja: '支出の取引チャネル割合' },
+13 -1
View File
@@ -126,6 +126,19 @@ router.beforeEach(async (to, _from, next) => {
return
}
// 已登录用户访问登录/注册页时,自动跳转到回跳地址或首页
const userStore = useUserStore()
if ((to.path === '/login' || to.path === '/register') && userStore.token) {
// 仅允许站内路径回跳,避免开放重定向
const redirect = Array.isArray(to.query.redirect) ? to.query.redirect[0] : to.query.redirect
const target =
typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
? redirect
: '/dashboard'
next(target)
return
}
// 注册功能关闭且已有用户时,不允许访问注册页
if (to.path === '/register') {
const registrationStore = useRegistrationStore()
@@ -138,7 +151,6 @@ router.beforeEach(async (to, _from, next) => {
}
}
const userStore = useUserStore()
// 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页并记录来源,登录后可回跳
if (to.path !== '/login' && to.path !== '/register' && !userStore.token) {
next({ path: '/login', query: { redirect: to.fullPath } })
+55 -36
View File
@@ -8,8 +8,8 @@ import {
} from 'naive-ui'
import type { UploadFileInfo } from 'naive-ui'
import { CloudUploadOutline } from '@vicons/ionicons5'
import { parseBill, confirmImport } from '../api/transaction'
import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction'
import { parseBill, confirmImport, getBillTypes } from '../api/transaction'
import type { BillType, BillTypeOption, ParsedBillRow, BillImportItem } from '../api/transaction'
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
import BillImportResult from '../components/BillImportResult.vue'
@@ -24,24 +24,18 @@ const { t } = useI18n() // 国际化
// 账单类型偏好存储键
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
// 账单类型选项,当前支持支付宝、微信、美团、招商银行与交通银行
const billTypeOptions = computed(() => [
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' },
{ label: t('import.billTypeMEITUAN'), value: 'MEITUAN' },
{ label: t('import.billTypeCMB'), value: 'CMB' },
{ label: t('import.billTypeBOCOM'), value: 'BOCOM' },
])
// 后端下发的账单类型(编码 + 允许的扩展名),组件挂载时加载
const billTypes = ref<BillTypeOption[]>([])
// 账单类型对应的名称与导出路径文案 key
const billTypeLabelKeys: Record<BillType, string> = {
// 账单类型对应的名称与导出路径文案 key(文案由前端按语言维护,新增来源未补文案时回退编码)
const billTypeLabelKeys: Record<string, string> = {
ALIPAY: 'import.billTypeALIPAY',
WECHAT: 'import.billTypeWECHAT',
MEITUAN: 'import.billTypeMEITUAN',
CMB: 'import.billTypeCMB',
BOCOM: 'import.billTypeBOCOM',
}
const billTypeExportHintKeys: Record<BillType, string> = {
const billTypeExportHintKeys: Record<string, string> = {
ALIPAY: 'import.exportHintALIPAY',
WECHAT: 'import.exportHintWECHAT',
MEITUAN: 'import.exportHintMEITUAN',
@@ -49,38 +43,62 @@ const billTypeExportHintKeys: Record<BillType, string> = {
BOCOM: 'import.exportHintBOCOM',
}
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
function loadBillTypePref(): BillType {
const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY)
return saved === 'ALIPAY' || saved === 'WECHAT' || saved === 'MEITUAN' || saved === 'CMB' || saved === 'BOCOM'
? saved
: 'ALIPAY'
/** 取账单类型对应的当前语言标签,未维护文案时回退编码 */
function billTypeLabel(code: string) {
const key = billTypeLabelKeys[code]
return key ? t(key) : code
}
// 当前账单类型,初始值取上次选择(个人偏好)
const billType = ref<BillType>(loadBillTypePref())
// 账单类型选项,来源于后端
const billTypeOptions = computed(() =>
billTypes.value.map((item) => ({ label: billTypeLabel(item.code), value: item.code })),
)
// 各账单类型允许上传的文件扩展名(支持原始文件或 zip 压缩包)
const allowedExtensions: Record<BillType, string[]> = {
ALIPAY: ['.csv', '.zip'],
WECHAT: ['.xlsx', '.zip'],
MEITUAN: ['.csv', '.zip'],
CMB: ['.csv', '.pdf', '.zip'],
BOCOM: ['.xls', '.zip'],
/** 读取上次选择的账单类型偏好 */
function readBillTypePref(): string {
return localStorage.getItem(BILL_TYPE_PREFS_KEY) || ''
}
// 当前账单类型,初始值取上次选择(个人偏好),加载后按后端列表校正
const billType = ref<BillType>(readBillTypePref())
// 各账单类型允许上传的文件扩展名(后端扩展名 + zip 压缩包)
const allowedExtensions = computed<Record<string, string[]>>(() => {
const map: Record<string, string[]> = {}
for (const item of billTypes.value) {
map[item.code] = [...item.extensions, '.zip']
}
return map
})
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
const acceptTypes = computed(() => (allowedExtensions.value[billType.value] || []).join(','))
// 当前账单类型的文件格式提示
const fileHint = computed(() => {
const label = t(billTypeLabelKeys[billType.value])
const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator'))
const label = billTypeLabel(billType.value)
const exts = (allowedExtensions.value[billType.value] || []).join(t('import.extSeparator'))
return t('import.fileHint', { label, exts })
})
// 当前账单类型的导出路径提示
const exportHint = computed(() => t(billTypeExportHintKeys[billType.value]))
const exportHint = computed(() => {
const key = billTypeExportHintKeys[billType.value]
return key ? t(key) : ''
})
/** 加载后端支持的账单类型,并按结果校正当前选择 */
async function loadBillTypes() {
try {
const res: any = await getBillTypes()
billTypes.value = res.data || []
const codes = billTypes.value.map((item) => item.code)
const saved = readBillTypePref()
billType.value = codes.includes(saved) ? saved : (codes[0] || '')
} catch (e) {
// 静默处理,账单类型下拉保持为空
}
}
// 面板阶段:选择文件 -> 预览勾选 -> 导入结果
const stage = ref<'select' | 'preview' | 'result'>('select')
@@ -100,7 +118,7 @@ const result = ref<{ total: number; imported: number; skipped: number; failed: n
/** 判断文件名是否符合当前账单类型允许的扩展名 */
function isAllowedFile(name: string) {
const exts = allowedExtensions[billType.value] || []
const exts = allowedExtensions.value[billType.value] || []
const lower = name.toLowerCase()
return exts.length === 0 || exts.some((ext) => lower.endsWith(ext))
}
@@ -109,8 +127,8 @@ function isAllowedFile(name: string) {
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
const last = options.fileList[options.fileList.length - 1]
if (last?.file && !isAllowedFile(last.name)) {
const label = t(billTypeLabelKeys[billType.value])
const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
const label = billTypeLabel(billType.value)
const exts = (allowedExtensions.value[billType.value] || []).join(t('import.extSeparator'))
message.error(t('import.errorFileType', { label, exts }))
uploadFileList.value = []
file.value = null
@@ -207,9 +225,10 @@ watch(billType, (value) => {
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
})
// 挂载时重置(页面进入或弹窗打开时均会触发)
// 挂载时重置并加载账单类型(页面进入或弹窗打开时均会触发)
onMounted(() => {
reset()
loadBillTypes()
})
</script>
+119 -5
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与交易渠道分布
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { ref, computed, onMounted, watch, nextTick, unref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton, NAutoComplete, NModal, NAlert, NDropdown, NIcon, useMessage } from 'naive-ui'
import { SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import VChart from 'vue-echarts'
@@ -24,6 +25,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipCompo
const { t, locale } = useI18n() // 国际化
const message = useMessage() // 消息提示
const router = useRouter() // 路由跳转
// 时间范围快捷选项
const rangeOptions = computed(() => [
@@ -146,9 +148,11 @@ const summary = ref<any>({
maxExpenseAmount: 0, maxExpenseTime: null, maxExpenseRemark: '', avgExpenseAmount: 0,
})
const trendData = ref<any[]>([]) // 趋势分组数据
const trendKeys = ref<string[]>([]) // 趋势图当前时间桶键序列(与柱状图 x 轴一一对应)
const categoryData = ref<any[]>([]) // 分类分组数据
const channelData = ref<any[]>([]) // 交易渠道分组数据
const trendChartRef = ref<any>(null) // 趋势图组件引用(用于取 ECharts 实例做像素换算)
const pieOption = ref({}) // 支出分类饼图
const incomePieOption = ref({}) // 收入分类饼图
const channelOption = ref({}) // 支出交易渠道饼图
@@ -301,6 +305,7 @@ function updateTrendChart() {
const income = keys.map((k) => Number(map.get(k)?.income ?? 0))
const expense = keys.map((k) => Number(map.get(k)?.expense ?? 0))
const labels = keys.map((k) => (granularity.value === 'day' ? k.substring(5) : k))
trendKeys.value = keys
trendOption.value = {
tooltip: { trigger: 'axis' },
@@ -309,12 +314,118 @@ function updateTrendChart() {
xAxis: { type: 'category', data: labels },
yAxis: { type: 'value' },
series: [
{ name: t('common.types.income'), type: 'bar', data: income, color: '#18a058' },
{ name: t('common.types.expense'), type: 'bar', data: expense, color: '#d03050' },
{ id: 'INCOME', name: t('common.types.income'), type: 'bar', data: income, color: '#18a058' },
{ id: 'EXPENSE', name: t('common.types.expense'), type: 'bar', data: expense, color: '#d03050' },
],
}
}
/** 将趋势分组的时间桶键换算为本地时间戳区间 [start, end],用于跳转收支记录页的日期筛选 */
function bucketRange(key: string, gran: string): [number, number] {
const startOfDay = (y: number, m: number, d: number) => new Date(y, m, d, 0, 0, 0).getTime()
const endOfDay = (y: number, m: number, d: number) => new Date(y, m, d, 23, 59, 59).getTime()
if (gran === 'day') {
const [y, m, d] = key.split('-').map(Number)
return [startOfDay(y, m - 1, d), endOfDay(y, m - 1, d)]
}
if (gran === 'week') {
const [y, w] = key.split('-W').map(Number)
// ISO 周:含 1 月 4 日的那一周为第 1 周,周一为起始
const jan4 = new Date(Date.UTC(y, 0, 4))
const jan4Day = jan4.getUTCDay() || 7
const week1Monday = new Date(Date.UTC(y, 0, 4 - jan4Day + 1))
const monday = new Date(week1Monday.getTime() + (w - 1) * 7 * 86400000)
const sunday = new Date(monday.getTime() + 6 * 86400000)
return [
startOfDay(monday.getUTCFullYear(), monday.getUTCMonth(), monday.getUTCDate()),
endOfDay(sunday.getUTCFullYear(), sunday.getUTCMonth(), sunday.getUTCDate()),
]
}
if (gran === 'month') {
const [y, m] = key.split('-').map(Number)
return [startOfDay(y, m - 1, 1), endOfDay(y, m, 0)]
}
if (gran === 'quarter') {
const [y, q] = key.split('-Q').map(Number)
const startMonth = (q - 1) * 3
return [startOfDay(y, startMonth, 1), endOfDay(y, startMonth + 3, 0)]
}
const y = Number(key)
return [startOfDay(y, 0, 1), endOfDay(y, 11, 31)]
}
/** 收支记录页筛选偏好存储键(与 TransactionList 保持一致) */
const TRANSACTION_FILTER_PREFS_KEY = 'finance.transaction.filterPrefs'
/**
* 将趋势时间桶与收支类型写入收支记录页筛选偏好,并跳转到收支记录
*
* :param key: 趋势时间桶键
* :param type: 收支类型;为空表示不限定类型(沿用面板当前类型筛选)
*/
function applyTrendNavigation(key: string, type: string | null) {
// 统计口径映射为收支记录页的计入统计三态
const statsScopeMap: Record<string, string | null> = {
INCLUDED: 'true',
EXCLUDED: 'false',
ALL: null,
}
try {
// 合并写入偏好,保留分页、排序等与本跳转无关的既有设置
const raw = localStorage.getItem(TRANSACTION_FILTER_PREFS_KEY)
const existing = raw ? JSON.parse(raw) : {}
localStorage.setItem(TRANSACTION_FILTER_PREFS_KEY, JSON.stringify({
...existing,
filterType: type ? [type] : filterType.value,
filterCategory: filterCategory.value,
filterChannel: filterChannel.value,
filterTag: filterTag.value,
filterDateRange: bucketRange(key, granularity.value),
filterIncludeInStats: statsScopeMap[filterStatsScope.value] ?? null,
filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value,
filterRemark: filterRemark.value.trim(),
}))
} catch (e) {
// 忽略写入失败,仍继续跳转
}
router.push('/transactions')
}
/**
* 命中趋势柱子的点击:按该柱子的时间桶与收支类型跳转收支记录页
*
* :param params: ECharts 元素点击事件参数(含 seriesId、dataIndex)
*/
function handleTrendClick(params: any) {
if (params?.componentType !== 'series' || params.dataIndex == null) return
const key = trendKeys.value[params.dataIndex]
if (!key) return
const type = params.seriesId === 'INCOME' ? 'INCOME' : 'EXPENSE'
applyTrendNavigation(key, type)
}
/**
* 点击趋势图空白处(含柱顶上方):整列可点,按所在时间桶跳转,不限定收支类型(沿用面板当前类型筛选)
*
* :param e: zrender 点击事件(含相对画布的 offsetX/offsetY)
*/
function handleTrendBlankClick(e: any) {
// 命中柱子等图形时交给 handleTrendClick 处理,避免重复跳转
if (e?.target) return
const chart = unref(trendChartRef.value?.chart)
if (!chart || e?.offsetX == null || e?.offsetY == null) return
const point: [number, number] = [e.offsetX, e.offsetY]
// 仅处理绘图区内的点击,忽略标题、图例、坐标轴等区域
if (!chart.containPixel({ gridIndex: 0 }, point)) return
// 反推点击位置所在的时间桶(类目轴返回类目索引)
const coord = chart.convertFromPixel({ gridIndex: 0 }, point)
if (!Array.isArray(coord)) return
const key = trendKeys.value[Math.round(coord[0])]
if (!key) return
applyTrendNavigation(key, null)
}
/** 并行请求所有统计数据并更新图表 */
async function fetchData() {
const { startTime, endTime } = range.value
@@ -742,11 +853,14 @@ onMounted(async () => {
<NCard style="margin-bottom: 16px;">
<template #header>
<NSpace justify="space-between" align="center">
<span>{{ t('dashboard.trend') }}</span>
<span>
{{ t('dashboard.trend') }}
<span style="color: #999; font-size: 12px; margin-left: 8px;">{{ t('dashboard.trendClickHint') }}</span>
</span>
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" />
</NSpace>
</template>
<VChart :option="trendOption" style="height: 350px;" autoresize />
<VChart ref="trendChartRef" :option="trendOption" style="height: 350px;" autoresize @click="handleTrendClick" @zr:click="handleTrendBlankClick" />
</NCard>
<!-- 饼图区域:支出/收入分类占比 + 支出/收入交易渠道占比 -->
+40 -11
View File
@@ -21,6 +21,8 @@ import type {
EmailBillSenderRulePayload,
} from '../api/emailBill'
import type { ParsedBillRow, BillType, BillImportItem } from '../api/transaction'
import { getBillTypes } from '../api/transaction'
import type { BillTypeOption } from '../api/transaction'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
import { renderCategoryLabel } from '../utils/render'
@@ -44,13 +46,25 @@ const EMAIL_BILL_TAB_KEY = 'emailBill.tab'
const activeTab = ref<string>((route.params.tab as string) || 'configs')
// ===== 公共选项 =====
const billTypeOptions = computed(() => [
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' },
{ label: t('import.billTypeMEITUAN'), value: 'MEITUAN' },
{ label: t('import.billTypeCMB'), value: 'CMB' },
{ label: t('import.billTypeBOCOM'), value: 'BOCOM' },
])
// 后端下发的账单类型(编码 + 允许的扩展名)
const billTypes = ref<BillTypeOption[]>([])
// 账单类型对应的名称文案 key(文案由前端按语言维护,新增来源未补文案时回退编码)
const billTypeLabelKeys: Record<string, string> = {
ALIPAY: 'import.billTypeALIPAY',
WECHAT: 'import.billTypeWECHAT',
MEITUAN: 'import.billTypeMEITUAN',
CMB: 'import.billTypeCMB',
BOCOM: 'import.billTypeBOCOM',
}
// 账单类型选项,来源于后端
const billTypeOptions = computed(() =>
billTypes.value.map((item) => ({
label: billTypeLabelKeys[item.code] ? t(billTypeLabelKeys[item.code]) : item.code,
value: item.code,
})),
)
/** 账单类型编码转当前语言标签,未知值回退原编码 */
function billTypeLabel(value: string | null) {
@@ -58,6 +72,21 @@ function billTypeLabel(value: string | null) {
return billTypeOptions.value.find((option) => option.value === value)?.label || value
}
/** 默认账单类型:取后端下发的第一个 */
function defaultBillType(): BillType {
return billTypeOptions.value[0]?.value || ''
}
/** 加载后端支持的账单类型 */
async function loadBillTypes() {
try {
const res: any = await getBillTypes()
billTypes.value = res.data || []
} catch (e) {
// 静默处理,账单类型下拉保持为空
}
}
/** 常用 IMAP 服务器候选项,选择后自动填充默认端口与 SSL */
const imapHostOptions = computed(() => [
{ label: `${t('emailBill.imapHosts.qq')} · imap.qq.com`, value: 'imap.qq.com' },
@@ -326,7 +355,7 @@ const ruleSaving = ref(false) // 发件人规则保存状态
function emptyRuleForm() {
return {
name: '', enabled: true, sortOrder: 0, patternsText: '', billType: 'ALIPAY' as BillType,
name: '', enabled: true, sortOrder: 0, patternsText: '', billType: defaultBillType(),
archivePassword: '', expenseCategoryId: null, incomeCategoryId: null, tagIds: [] as number[],
}
}
@@ -560,7 +589,7 @@ function handleDeleteMessage(row: EmailBillMessage) {
const parseModalVisible = ref(false)
const parseStage = ref<'select' | 'preview' | 'result'>('select')
const parseMessageRow = ref<EmailBillMessage | null>(null)
const parseBillType = ref<BillType>('ALIPAY')
const parseBillType = ref<BillType>(defaultBillType())
const parsePassword = ref('')
const parsing = ref(false)
const importing = ref(false)
@@ -572,7 +601,7 @@ const parseResult = ref<{ total: number; imported: number; skipped: number; fail
function openParseModal(row: EmailBillMessage) {
parseMessageRow.value = row
parseStage.value = 'select'
parseBillType.value = (row.billType || 'ALIPAY') as BillType
parseBillType.value = (row.billType || defaultBillType()) as BillType
parsePassword.value = ''
parsedRows.value = []
parseResult.value = null
@@ -645,7 +674,7 @@ watch(activeTab, (tab) => {
let timer: number | null = null
onMounted(async () => {
await loadOptions()
await Promise.all([loadOptions(), loadBillTypes()])
await Promise.all([loadConfigs(), loadSenderRules(), loadMessages()])
timer = window.setInterval(() => {
if (activeTab.value === 'messages') {
+118 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 主布局组件:包含左侧导航菜单、顶部用户信息栏和内容区域
import { h, computed } from 'vue'
import { h, computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
@@ -76,6 +76,122 @@ function handleLogout() {
userStore.logout()
router.push('/login')
}
// 内容区滚动位置记忆:按路由分别记录,进入页面时自动恢复
const contentRef = ref<InstanceType<typeof NLayoutContent> | null>(null) // 内容区滚动容器
const SCROLL_KEY_PREFIX = 'finance.scroll.' // sessionStorage 存储键前缀
let scrollRaf = 0 // scroll 事件的 rAF 节流句柄
let restoreRaf = 0 // 恢复滚动位置的 rAF 轮询句柄
/** 获取内容区滚动元素 */
function getScrollEl(): HTMLElement | null {
return (contentRef.value as any)?.$el ?? null
}
/** 生成指定路由的滚动位置存储键 */
function scrollKey(path: string) {
return `${SCROLL_KEY_PREFIX}${path}`
}
/** 保存指定路由的滚动位置到 sessionStorage */
function saveScroll(path: string = route.path) {
const el = getScrollEl()
if (!el) return
try {
sessionStorage.setItem(scrollKey(path), String(el.scrollTop))
} catch (e) {
// 忽略存储异常(如隐私模式)
}
}
/** 读取指定路由的滚动位置,无记录时返回 0 */
function readScroll(path: string): number {
try {
return Number(sessionStorage.getItem(scrollKey(path))) || 0
} catch (e) {
return 0
}
}
/** scroll 事件处理:rAF 节流,避免高频写入 sessionStorage */
function handleScroll() {
if (scrollRaf) return
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0
saveScroll()
})
}
/** 取消未完成的滚动恢复 */
function cancelRestore() {
if (restoreRaf) {
cancelAnimationFrame(restoreRaf)
restoreRaf = 0
}
}
/**
* 恢复指定路由的滚动位置。
* 页面内容多为异步加载,切换后高度可能不足,故用 rAF 轮询等待内容高度足够再定位;
* 超时后按当前可达的最大值定位,避免无限等待。
*/
function restoreScroll(path: string) {
cancelRestore()
const target = readScroll(path)
const el = getScrollEl()
if (!el) return
if (target <= 0) {
el.scrollTop = 0
return
}
const start = performance.now()
const tick = () => {
const node = getScrollEl()
if (!node) {
restoreRaf = 0
return
}
const max = node.scrollHeight - node.clientHeight
if (max >= target) {
node.scrollTop = target
restoreRaf = 0
return
}
if (performance.now() - start > 2000) {
node.scrollTop = Math.max(0, max)
restoreRaf = 0
return
}
restoreRaf = requestAnimationFrame(tick)
}
restoreRaf = requestAnimationFrame(tick)
}
/** 页面卸载前保存当前位置,兜底刷新场景 */
function handleBeforeUnload() {
saveScroll()
}
// 路由切换:先保存离开页的位置,再在 DOM 更新后恢复目标页的位置
watch(() => route.path, (to, from) => {
saveScroll(from)
cancelRestore()
nextTick(() => restoreScroll(to))
})
onMounted(() => {
getScrollEl()?.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('beforeunload', handleBeforeUnload)
// 刷新或直接进入页面时恢复当前位置
restoreScroll(route.path)
})
onBeforeUnmount(() => {
getScrollEl()?.removeEventListener('scroll', handleScroll)
window.removeEventListener('beforeunload', handleBeforeUnload)
if (scrollRaf) cancelAnimationFrame(scrollRaf)
cancelRestore()
})
</script>
<template>
@@ -117,7 +233,7 @@ function handleLogout() {
</NSpace>
</NLayoutHeader>
<!-- 子路由内容渲染区域 -->
<NLayoutContent content-style="padding: 24px;" style="height: calc(100vh - 57px); overflow-y: auto;">
<NLayoutContent ref="contentRef" content-style="padding: 24px;" style="height: calc(100vh - 57px); overflow-y: auto;">
<router-view />
</NLayoutContent>
</NLayout>
+2
View File
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue'
import DisplayConfigPanel from '../components/DisplayConfigPanel.vue'
import AiConfigPanel from '../components/AiConfigPanel.vue'
import NotificationConfigPanel from '../components/NotificationConfigPanel.vue'
@@ -16,6 +17,7 @@ const { t } = useI18n()
<DatabaseConfigPanel />
<RegistrationConfigPanel />
<BillSourceStatsPanel />
<DisplayConfigPanel />
<AiConfigPanel />
<NotificationConfigPanel />
</div>
+27 -1
View File
@@ -8,6 +8,7 @@ import type { DataTableColumns } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions } from '../api/transaction'
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
import { getExcludedDisplayStyle } from '../api/setting'
import { getCategoryList, createCategory } from '../api/category'
import { getTagList, createTag } from '../api/tag'
import { getChannelLabel } from '../utils/channel'
@@ -92,6 +93,8 @@ const pageExcludedSummary = computed(() => {
return { income, expense }
})
const loading = ref(false) // 列表加载状态
// 「不计入统计」记录的展示样式:strikethrough-整行删除线(默认)、muted-灰色标签
const excludedDisplayStyle = ref<'strikethrough' | 'muted'>('strikethrough')
const submitting = ref(false) // 表单提交状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const showImportModal = ref(false) // 账单导入弹窗显示状态
@@ -424,6 +427,8 @@ async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
/** 创建表格列配置 */
function createColumns(): DataTableColumns<TransactionItem> {
// 读取展示样式,使列配置随样式偏好变化而重新计算
const muted = excludedDisplayStyle.value === 'muted'
return [
// 多选列:用于批量操作(如批量删除)
{ type: 'selection' },
@@ -445,6 +450,10 @@ function createColumns(): DataTableColumns<TransactionItem> {
{
title: t('transaction.column.type'), key: 'type', width: 90,
render(row) {
// 灰色标签样式下,不计入统计的记录类型显示为「不计入」
if (muted && !row.includeInStats) {
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('transaction.notIncluded') })
}
return h(NTag, { type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' }, { default: () => row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense') })
},
},
@@ -458,6 +467,10 @@ function createColumns(): DataTableColumns<TransactionItem> {
{
title: t('transaction.column.amount'), key: 'amount', width: 120,
render(row) {
// 灰色标签样式下,不计入统计的记录金额置灰且不显示正负号
if (muted && !row.includeInStats) {
return h('span', { style: { color: '#999', fontWeight: 'bold' } }, row.amount.toFixed(2))
}
return h('span', { style: { color: row.type === 'INCOME' ? '#18a058' : '#d03050', fontWeight: 'bold' } },
`${row.type === 'INCOME' ? '+' : '-'}${row.amount.toFixed(2)}`)
},
@@ -535,6 +548,18 @@ async function loadChannels() {
}
}
/** 加载「不计入统计」记录展示样式偏好,失败时保持默认删除线 */
async function loadExcludedDisplayStyle() {
try {
const res: any = await getExcludedDisplayStyle()
if (res.data === 'muted' || res.data === 'strikethrough') {
excludedDisplayStyle.value = res.data
}
} catch (e) {
// 静默处理,保持默认样式
}
}
/** 加载分类列表 */
async function fetchCategories() {
try {
@@ -1487,6 +1512,7 @@ onMounted(() => {
remarkHistory.load()
aiQueryHistory.load()
loadAiConfig()
loadExcludedDisplayStyle()
fetchCategories()
fetchTags()
loadChannels()
@@ -1688,7 +1714,7 @@ onMounted(() => {
:loading="loading"
:bordered="false"
:row-key="(row: TransactionItem) => row.id"
:row-class-name="(row: TransactionItem) => row.includeInStats ? '' : 'excluded-row'"
:row-class-name="(row: TransactionItem) => (!row.includeInStats && excludedDisplayStyle === 'strikethrough') ? 'excluded-row' : ''"
v-model:checked-row-keys="checkedTransactionIds"
/>