feat: 用户级偏好配置与外观设置,枚举统一抽取与账单类型后端下发
- 新增用户偏好表 t_user_preference 与进程内缓存,账单来源统计默认值由全局改为按用户存储(/api/setting) - 新增外观设置:不计入统计记录支持删除线与灰色标签两种展示样式 - 抽取 ChannelType / TransactionType / StatisticsScope 枚举,替换各处硬编码的渠道、类型与统计口径 - BillType 内聚各来源扩展名,新增 /transaction/import/types 接口,前端改为按后端下发渲染 - 仪表盘趋势图支持点击柱子跳转收支记录并带入筛选条件 - 布局内容区按路由记忆滚动位置;已登录访问登录/注册页自动跳转
This commit is contained in:
@@ -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 })
|
|
||||||
}
|
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -77,8 +77,19 @@ export function toggleRemarkHidden(id: number, remarkHidden: boolean) {
|
|||||||
return request.put(`/transaction/${id}/remark-hidden`, { remarkHidden })
|
return request.put(`/transaction/${id}/remark-hidden`, { remarkHidden })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 支持的账单类型,当前支持支付宝、微信、美团、招商银行与交通银行 */
|
/** 支持的账单类型编码(由后端 /transaction/import/types 下发,此处为已知值提示) */
|
||||||
export type BillType = 'ALIPAY' | 'WECHAT' | 'MEITUAN' | 'CMB' | 'BOCOM'
|
export type BillType = string
|
||||||
|
|
||||||
|
/** 账单类型选项(后端下发),extensions 为该来源允许的账单文件扩展名 */
|
||||||
|
export interface BillTypeOption {
|
||||||
|
code: string
|
||||||
|
extensions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取系统支持的账单类型及允许的文件扩展名 */
|
||||||
|
export function getBillTypes() {
|
||||||
|
return request.get('/transaction/import/types')
|
||||||
|
}
|
||||||
|
|
||||||
/** 账单解析结果行 */
|
/** 账单解析结果行 */
|
||||||
export interface ParsedBillRow {
|
export interface ParsedBillRow {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import {
|
import {
|
||||||
useMessage, NCard, NSpace, NSwitch, NButton, NIcon, NText,
|
useMessage, NCard, NSpace, NSwitch, NButton, NIcon, NTag, NAlert,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { OptionsOutline, SaveOutline } from '@vicons/ionicons5'
|
import { OptionsOutline, SaveOutline } from '@vicons/ionicons5'
|
||||||
import { getBillSourceIncludeInStats, updateBillSourceIncludeInStats } from '../api/app'
|
import { getBillSourceIncludeInStats, updateBillSourceIncludeInStats } from '../api/setting'
|
||||||
import { getChannelLabel } from '../utils/channel'
|
import { getChannelLabel } from '../utils/channel'
|
||||||
|
|
||||||
const message = useMessage()
|
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)
|
onMounted(loadDefaults)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -55,17 +61,24 @@ onMounted(loadDefaults)
|
|||||||
</NSpace>
|
</NSpace>
|
||||||
</template>
|
</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 class="source-list">
|
||||||
<div v-for="(value, code) in defaults" :key="code" class="source-row">
|
<div
|
||||||
<NSpace align="center" :size="12">
|
v-for="(value, code) in defaults"
|
||||||
<NSwitch v-model:value="defaults[code]" :disabled="loading" />
|
:key="code"
|
||||||
<span>{{ getChannelLabel(String(code)) }}</span>
|
class="source-row"
|
||||||
<NText depth="3" style="font-size: 12px;">
|
@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') }}
|
{{ value ? t('billSourceStats.included') : t('billSourceStats.excluded') }}
|
||||||
</NText>
|
</NTag>
|
||||||
</NSpace>
|
<NSwitch v-model:value="defaults[code]" :disabled="loading" @click.stop />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,9 +95,32 @@ onMounted(loadDefaults)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.source-list {
|
.source-list {
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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>
|
</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>
|
||||||
@@ -137,6 +137,7 @@ export default {
|
|||||||
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
|
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
|
||||||
registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' },
|
registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' },
|
||||||
billSourceStatsSection: { zh: '账单来源统计默认值', en: 'Bill Source Stats Defaults', ja: '請求元の集計既定値' },
|
billSourceStatsSection: { zh: '账单来源统计默认值', en: 'Bill Source Stats Defaults', ja: '請求元の集計既定値' },
|
||||||
|
displaySection: { zh: '外观设置', en: 'Appearance', ja: '外観設定' },
|
||||||
notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' },
|
notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' },
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -151,6 +152,21 @@ export default {
|
|||||||
saveFailed: { zh: '保存账单来源统计默认值失败', en: 'Failed to save bill source stats defaults', ja: '請求元の集計既定値の保存に失敗しました' },
|
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: {
|
registration: {
|
||||||
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
|
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
|
||||||
@@ -331,6 +347,7 @@ export default {
|
|||||||
maxExpense: { zh: '最大单笔支出', en: 'Largest Expense', ja: '最大の支出' },
|
maxExpense: { zh: '最大单笔支出', en: 'Largest Expense', ja: '最大の支出' },
|
||||||
avgExpense: { zh: '平均每笔支出', en: 'Avg. Expense', ja: '平均支出' },
|
avgExpense: { zh: '平均每笔支出', en: 'Avg. Expense', ja: '平均支出' },
|
||||||
trend: { zh: '收支趋势', en: 'Income & Expense Trend', 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: '支出カテゴリの割合' },
|
expenseCategoryPie: { zh: '支出分类占比', en: 'Expense by Category', ja: '支出カテゴリの割合' },
|
||||||
incomeCategoryPie: { zh: '收入分类占比', en: 'Income by Category', ja: '収入カテゴリの割合' },
|
incomeCategoryPie: { zh: '收入分类占比', en: 'Income by Category', ja: '収入カテゴリの割合' },
|
||||||
expenseChannelPie: { zh: '支出交易渠道占比', en: 'Expense by Channel', ja: '支出の取引チャネル割合' },
|
expenseChannelPie: { zh: '支出交易渠道占比', en: 'Expense by Channel', ja: '支出の取引チャネル割合' },
|
||||||
|
|||||||
@@ -126,6 +126,19 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
return
|
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') {
|
if (to.path === '/register') {
|
||||||
const registrationStore = useRegistrationStore()
|
const registrationStore = useRegistrationStore()
|
||||||
@@ -138,7 +151,6 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
// 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页并记录来源,登录后可回跳
|
// 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页并记录来源,登录后可回跳
|
||||||
if (to.path !== '/login' && to.path !== '/register' && !userStore.token) {
|
if (to.path !== '/login' && to.path !== '/register' && !userStore.token) {
|
||||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import type { UploadFileInfo } from 'naive-ui'
|
import type { UploadFileInfo } from 'naive-ui'
|
||||||
import { CloudUploadOutline } from '@vicons/ionicons5'
|
import { CloudUploadOutline } from '@vicons/ionicons5'
|
||||||
import { parseBill, confirmImport } from '../api/transaction'
|
import { parseBill, confirmImport, getBillTypes } from '../api/transaction'
|
||||||
import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction'
|
import type { BillType, BillTypeOption, ParsedBillRow, BillImportItem } from '../api/transaction'
|
||||||
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
|
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
|
||||||
import BillImportResult from '../components/BillImportResult.vue'
|
import BillImportResult from '../components/BillImportResult.vue'
|
||||||
|
|
||||||
@@ -24,24 +24,18 @@ const { t } = useI18n() // 国际化
|
|||||||
// 账单类型偏好存储键
|
// 账单类型偏好存储键
|
||||||
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
|
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
|
||||||
|
|
||||||
// 账单类型选项,当前支持支付宝、微信、美团、招商银行与交通银行
|
// 后端下发的账单类型(编码 + 允许的扩展名),组件挂载时加载
|
||||||
const billTypeOptions = computed(() => [
|
const billTypes = ref<BillTypeOption[]>([])
|
||||||
{ 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' },
|
|
||||||
])
|
|
||||||
|
|
||||||
// 账单类型对应的名称与导出路径文案 key
|
// 账单类型对应的名称与导出路径文案 key(文案由前端按语言维护,新增来源未补文案时回退编码)
|
||||||
const billTypeLabelKeys: Record<BillType, string> = {
|
const billTypeLabelKeys: Record<string, string> = {
|
||||||
ALIPAY: 'import.billTypeALIPAY',
|
ALIPAY: 'import.billTypeALIPAY',
|
||||||
WECHAT: 'import.billTypeWECHAT',
|
WECHAT: 'import.billTypeWECHAT',
|
||||||
MEITUAN: 'import.billTypeMEITUAN',
|
MEITUAN: 'import.billTypeMEITUAN',
|
||||||
CMB: 'import.billTypeCMB',
|
CMB: 'import.billTypeCMB',
|
||||||
BOCOM: 'import.billTypeBOCOM',
|
BOCOM: 'import.billTypeBOCOM',
|
||||||
}
|
}
|
||||||
const billTypeExportHintKeys: Record<BillType, string> = {
|
const billTypeExportHintKeys: Record<string, string> = {
|
||||||
ALIPAY: 'import.exportHintALIPAY',
|
ALIPAY: 'import.exportHintALIPAY',
|
||||||
WECHAT: 'import.exportHintWECHAT',
|
WECHAT: 'import.exportHintWECHAT',
|
||||||
MEITUAN: 'import.exportHintMEITUAN',
|
MEITUAN: 'import.exportHintMEITUAN',
|
||||||
@@ -49,38 +43,62 @@ const billTypeExportHintKeys: Record<BillType, string> = {
|
|||||||
BOCOM: 'import.exportHintBOCOM',
|
BOCOM: 'import.exportHintBOCOM',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
|
/** 取账单类型对应的当前语言标签,未维护文案时回退编码 */
|
||||||
function loadBillTypePref(): BillType {
|
function billTypeLabel(code: string) {
|
||||||
const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY)
|
const key = billTypeLabelKeys[code]
|
||||||
return saved === 'ALIPAY' || saved === 'WECHAT' || saved === 'MEITUAN' || saved === 'CMB' || saved === 'BOCOM'
|
return key ? t(key) : code
|
||||||
? saved
|
|
||||||
: 'ALIPAY'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前账单类型,初始值取上次选择(个人偏好)
|
// 账单类型选项,来源于后端
|
||||||
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[]> = {
|
function readBillTypePref(): string {
|
||||||
ALIPAY: ['.csv', '.zip'],
|
return localStorage.getItem(BILL_TYPE_PREFS_KEY) || ''
|
||||||
WECHAT: ['.xlsx', '.zip'],
|
|
||||||
MEITUAN: ['.csv', '.zip'],
|
|
||||||
CMB: ['.csv', '.pdf', '.zip'],
|
|
||||||
BOCOM: ['.xls', '.zip'],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 当前账单类型,初始值取上次选择(个人偏好),加载后按后端列表校正
|
||||||
|
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 属性
|
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
|
||||||
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
|
const acceptTypes = computed(() => (allowedExtensions.value[billType.value] || []).join(','))
|
||||||
|
|
||||||
// 当前账单类型的文件格式提示
|
// 当前账单类型的文件格式提示
|
||||||
const fileHint = computed(() => {
|
const fileHint = computed(() => {
|
||||||
const label = t(billTypeLabelKeys[billType.value])
|
const label = billTypeLabel(billType.value)
|
||||||
const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator'))
|
const exts = (allowedExtensions.value[billType.value] || []).join(t('import.extSeparator'))
|
||||||
return t('import.fileHint', { label, exts })
|
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')
|
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) {
|
function isAllowedFile(name: string) {
|
||||||
const exts = allowedExtensions[billType.value] || []
|
const exts = allowedExtensions.value[billType.value] || []
|
||||||
const lower = name.toLowerCase()
|
const lower = name.toLowerCase()
|
||||||
return exts.length === 0 || exts.some((ext) => lower.endsWith(ext))
|
return exts.length === 0 || exts.some((ext) => lower.endsWith(ext))
|
||||||
}
|
}
|
||||||
@@ -109,8 +127,8 @@ function isAllowedFile(name: string) {
|
|||||||
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
|
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
|
||||||
const last = options.fileList[options.fileList.length - 1]
|
const last = options.fileList[options.fileList.length - 1]
|
||||||
if (last?.file && !isAllowedFile(last.name)) {
|
if (last?.file && !isAllowedFile(last.name)) {
|
||||||
const label = t(billTypeLabelKeys[billType.value])
|
const label = billTypeLabel(billType.value)
|
||||||
const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
|
const exts = (allowedExtensions.value[billType.value] || []).join(t('import.extSeparator'))
|
||||||
message.error(t('import.errorFileType', { label, exts }))
|
message.error(t('import.errorFileType', { label, exts }))
|
||||||
uploadFileList.value = []
|
uploadFileList.value = []
|
||||||
file.value = null
|
file.value = null
|
||||||
@@ -207,9 +225,10 @@ watch(billType, (value) => {
|
|||||||
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
|
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 挂载时重置(页面进入或弹窗打开时均会触发)
|
// 挂载时重置并加载账单类型(页面进入或弹窗打开时均会触发)
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
reset()
|
reset()
|
||||||
|
loadBillTypes()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<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 { 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 { 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 { SparklesOutline, BulbOutline } from '@vicons/ionicons5'
|
||||||
import VChart from 'vue-echarts'
|
import VChart from 'vue-echarts'
|
||||||
@@ -24,6 +25,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipCompo
|
|||||||
|
|
||||||
const { t, locale } = useI18n() // 国际化
|
const { t, locale } = useI18n() // 国际化
|
||||||
const message = useMessage() // 消息提示
|
const message = useMessage() // 消息提示
|
||||||
|
const router = useRouter() // 路由跳转
|
||||||
|
|
||||||
// 时间范围快捷选项
|
// 时间范围快捷选项
|
||||||
const rangeOptions = computed(() => [
|
const rangeOptions = computed(() => [
|
||||||
@@ -146,9 +148,11 @@ const summary = ref<any>({
|
|||||||
maxExpenseAmount: 0, maxExpenseTime: null, maxExpenseRemark: '', avgExpenseAmount: 0,
|
maxExpenseAmount: 0, maxExpenseTime: null, maxExpenseRemark: '', avgExpenseAmount: 0,
|
||||||
})
|
})
|
||||||
const trendData = ref<any[]>([]) // 趋势分组数据
|
const trendData = ref<any[]>([]) // 趋势分组数据
|
||||||
|
const trendKeys = ref<string[]>([]) // 趋势图当前时间桶键序列(与柱状图 x 轴一一对应)
|
||||||
const categoryData = ref<any[]>([]) // 分类分组数据
|
const categoryData = ref<any[]>([]) // 分类分组数据
|
||||||
const channelData = ref<any[]>([]) // 交易渠道分组数据
|
const channelData = ref<any[]>([]) // 交易渠道分组数据
|
||||||
|
|
||||||
|
const trendChartRef = ref<any>(null) // 趋势图组件引用(用于取 ECharts 实例做像素换算)
|
||||||
const pieOption = ref({}) // 支出分类饼图
|
const pieOption = ref({}) // 支出分类饼图
|
||||||
const incomePieOption = ref({}) // 收入分类饼图
|
const incomePieOption = ref({}) // 收入分类饼图
|
||||||
const channelOption = ref({}) // 支出交易渠道饼图
|
const channelOption = ref({}) // 支出交易渠道饼图
|
||||||
@@ -301,6 +305,7 @@ function updateTrendChart() {
|
|||||||
const income = keys.map((k) => Number(map.get(k)?.income ?? 0))
|
const income = keys.map((k) => Number(map.get(k)?.income ?? 0))
|
||||||
const expense = keys.map((k) => Number(map.get(k)?.expense ?? 0))
|
const expense = keys.map((k) => Number(map.get(k)?.expense ?? 0))
|
||||||
const labels = keys.map((k) => (granularity.value === 'day' ? k.substring(5) : k))
|
const labels = keys.map((k) => (granularity.value === 'day' ? k.substring(5) : k))
|
||||||
|
trendKeys.value = keys
|
||||||
|
|
||||||
trendOption.value = {
|
trendOption.value = {
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
@@ -309,12 +314,118 @@ function updateTrendChart() {
|
|||||||
xAxis: { type: 'category', data: labels },
|
xAxis: { type: 'category', data: labels },
|
||||||
yAxis: { type: 'value' },
|
yAxis: { type: 'value' },
|
||||||
series: [
|
series: [
|
||||||
{ name: t('common.types.income'), type: 'bar', data: income, color: '#18a058' },
|
{ id: 'INCOME', name: t('common.types.income'), type: 'bar', data: income, color: '#18a058' },
|
||||||
{ name: t('common.types.expense'), type: 'bar', data: expense, color: '#d03050' },
|
{ 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() {
|
async function fetchData() {
|
||||||
const { startTime, endTime } = range.value
|
const { startTime, endTime } = range.value
|
||||||
@@ -742,11 +853,14 @@ onMounted(async () => {
|
|||||||
<NCard style="margin-bottom: 16px;">
|
<NCard style="margin-bottom: 16px;">
|
||||||
<template #header>
|
<template #header>
|
||||||
<NSpace justify="space-between" align="center">
|
<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" />
|
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" />
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</template>
|
</template>
|
||||||
<VChart :option="trendOption" style="height: 350px;" autoresize />
|
<VChart ref="trendChartRef" :option="trendOption" style="height: 350px;" autoresize @click="handleTrendClick" @zr:click="handleTrendBlankClick" />
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
<!-- 饼图区域:支出/收入分类占比 + 支出/收入交易渠道占比 -->
|
<!-- 饼图区域:支出/收入分类占比 + 支出/收入交易渠道占比 -->
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import type {
|
|||||||
EmailBillSenderRulePayload,
|
EmailBillSenderRulePayload,
|
||||||
} from '../api/emailBill'
|
} from '../api/emailBill'
|
||||||
import type { ParsedBillRow, BillType, BillImportItem } from '../api/transaction'
|
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 { getCategoryList } from '../api/category'
|
||||||
import { getTagList } from '../api/tag'
|
import { getTagList } from '../api/tag'
|
||||||
import { renderCategoryLabel } from '../utils/render'
|
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 activeTab = ref<string>((route.params.tab as string) || 'configs')
|
||||||
|
|
||||||
// ===== 公共选项 =====
|
// ===== 公共选项 =====
|
||||||
const billTypeOptions = computed(() => [
|
// 后端下发的账单类型(编码 + 允许的扩展名)
|
||||||
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
|
const billTypes = ref<BillTypeOption[]>([])
|
||||||
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' },
|
|
||||||
{ label: t('import.billTypeMEITUAN'), value: 'MEITUAN' },
|
// 账单类型对应的名称文案 key(文案由前端按语言维护,新增来源未补文案时回退编码)
|
||||||
{ label: t('import.billTypeCMB'), value: 'CMB' },
|
const billTypeLabelKeys: Record<string, string> = {
|
||||||
{ label: t('import.billTypeBOCOM'), value: 'BOCOM' },
|
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) {
|
function billTypeLabel(value: string | null) {
|
||||||
@@ -58,6 +72,21 @@ function billTypeLabel(value: string | null) {
|
|||||||
return billTypeOptions.value.find((option) => option.value === value)?.label || value
|
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 */
|
/** 常用 IMAP 服务器候选项,选择后自动填充默认端口与 SSL */
|
||||||
const imapHostOptions = computed(() => [
|
const imapHostOptions = computed(() => [
|
||||||
{ label: `${t('emailBill.imapHosts.qq')} · imap.qq.com`, value: 'imap.qq.com' },
|
{ label: `${t('emailBill.imapHosts.qq')} · imap.qq.com`, value: 'imap.qq.com' },
|
||||||
@@ -326,7 +355,7 @@ const ruleSaving = ref(false) // 发件人规则保存状态
|
|||||||
|
|
||||||
function emptyRuleForm() {
|
function emptyRuleForm() {
|
||||||
return {
|
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[],
|
archivePassword: '', expenseCategoryId: null, incomeCategoryId: null, tagIds: [] as number[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -560,7 +589,7 @@ function handleDeleteMessage(row: EmailBillMessage) {
|
|||||||
const parseModalVisible = ref(false)
|
const parseModalVisible = ref(false)
|
||||||
const parseStage = ref<'select' | 'preview' | 'result'>('select')
|
const parseStage = ref<'select' | 'preview' | 'result'>('select')
|
||||||
const parseMessageRow = ref<EmailBillMessage | null>(null)
|
const parseMessageRow = ref<EmailBillMessage | null>(null)
|
||||||
const parseBillType = ref<BillType>('ALIPAY')
|
const parseBillType = ref<BillType>(defaultBillType())
|
||||||
const parsePassword = ref('')
|
const parsePassword = ref('')
|
||||||
const parsing = ref(false)
|
const parsing = ref(false)
|
||||||
const importing = ref(false)
|
const importing = ref(false)
|
||||||
@@ -572,7 +601,7 @@ const parseResult = ref<{ total: number; imported: number; skipped: number; fail
|
|||||||
function openParseModal(row: EmailBillMessage) {
|
function openParseModal(row: EmailBillMessage) {
|
||||||
parseMessageRow.value = row
|
parseMessageRow.value = row
|
||||||
parseStage.value = 'select'
|
parseStage.value = 'select'
|
||||||
parseBillType.value = (row.billType || 'ALIPAY') as BillType
|
parseBillType.value = (row.billType || defaultBillType()) as BillType
|
||||||
parsePassword.value = ''
|
parsePassword.value = ''
|
||||||
parsedRows.value = []
|
parsedRows.value = []
|
||||||
parseResult.value = null
|
parseResult.value = null
|
||||||
@@ -645,7 +674,7 @@ watch(activeTab, (tab) => {
|
|||||||
let timer: number | null = null
|
let timer: number | null = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadOptions()
|
await Promise.all([loadOptions(), loadBillTypes()])
|
||||||
await Promise.all([loadConfigs(), loadSenderRules(), loadMessages()])
|
await Promise.all([loadConfigs(), loadSenderRules(), loadMessages()])
|
||||||
timer = window.setInterval(() => {
|
timer = window.setInterval(() => {
|
||||||
if (activeTab.value === 'messages') {
|
if (activeTab.value === 'messages') {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
|
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
|
||||||
@@ -76,6 +76,122 @@ function handleLogout() {
|
|||||||
userStore.logout()
|
userStore.logout()
|
||||||
router.push('/login')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -117,7 +233,7 @@ function handleLogout() {
|
|||||||
</NSpace>
|
</NSpace>
|
||||||
</NLayoutHeader>
|
</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 />
|
<router-view />
|
||||||
</NLayoutContent>
|
</NLayoutContent>
|
||||||
</NLayout>
|
</NLayout>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
|
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
|
||||||
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
|
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
|
||||||
import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue'
|
import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue'
|
||||||
|
import DisplayConfigPanel from '../components/DisplayConfigPanel.vue'
|
||||||
import AiConfigPanel from '../components/AiConfigPanel.vue'
|
import AiConfigPanel from '../components/AiConfigPanel.vue'
|
||||||
import NotificationConfigPanel from '../components/NotificationConfigPanel.vue'
|
import NotificationConfigPanel from '../components/NotificationConfigPanel.vue'
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const { t } = useI18n()
|
|||||||
<DatabaseConfigPanel />
|
<DatabaseConfigPanel />
|
||||||
<RegistrationConfigPanel />
|
<RegistrationConfigPanel />
|
||||||
<BillSourceStatsPanel />
|
<BillSourceStatsPanel />
|
||||||
|
<DisplayConfigPanel />
|
||||||
<AiConfigPanel />
|
<AiConfigPanel />
|
||||||
<NotificationConfigPanel />
|
<NotificationConfigPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { 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 { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
|
||||||
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
|
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
|
||||||
|
import { getExcludedDisplayStyle } from '../api/setting'
|
||||||
import { getCategoryList, createCategory } from '../api/category'
|
import { getCategoryList, createCategory } from '../api/category'
|
||||||
import { getTagList, createTag } from '../api/tag'
|
import { getTagList, createTag } from '../api/tag'
|
||||||
import { getChannelLabel } from '../utils/channel'
|
import { getChannelLabel } from '../utils/channel'
|
||||||
@@ -92,6 +93,8 @@ const pageExcludedSummary = computed(() => {
|
|||||||
return { income, expense }
|
return { income, expense }
|
||||||
})
|
})
|
||||||
const loading = ref(false) // 列表加载状态
|
const loading = ref(false) // 列表加载状态
|
||||||
|
// 「不计入统计」记录的展示样式:strikethrough-整行删除线(默认)、muted-灰色标签
|
||||||
|
const excludedDisplayStyle = ref<'strikethrough' | 'muted'>('strikethrough')
|
||||||
const submitting = ref(false) // 表单提交状态
|
const submitting = ref(false) // 表单提交状态
|
||||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||||
const showImportModal = ref(false) // 账单导入弹窗显示状态
|
const showImportModal = ref(false) // 账单导入弹窗显示状态
|
||||||
@@ -424,6 +427,8 @@ async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
|
|||||||
|
|
||||||
/** 创建表格列配置 */
|
/** 创建表格列配置 */
|
||||||
function createColumns(): DataTableColumns<TransactionItem> {
|
function createColumns(): DataTableColumns<TransactionItem> {
|
||||||
|
// 读取展示样式,使列配置随样式偏好变化而重新计算
|
||||||
|
const muted = excludedDisplayStyle.value === 'muted'
|
||||||
return [
|
return [
|
||||||
// 多选列:用于批量操作(如批量删除)
|
// 多选列:用于批量操作(如批量删除)
|
||||||
{ type: 'selection' },
|
{ type: 'selection' },
|
||||||
@@ -445,6 +450,10 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
|||||||
{
|
{
|
||||||
title: t('transaction.column.type'), key: 'type', width: 90,
|
title: t('transaction.column.type'), key: 'type', width: 90,
|
||||||
render(row) {
|
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') })
|
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,
|
title: t('transaction.column.amount'), key: 'amount', width: 120,
|
||||||
render(row) {
|
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' } },
|
return h('span', { style: { color: row.type === 'INCOME' ? '#18a058' : '#d03050', fontWeight: 'bold' } },
|
||||||
`${row.type === 'INCOME' ? '+' : '-'}${row.amount.toFixed(2)}`)
|
`${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() {
|
async function fetchCategories() {
|
||||||
try {
|
try {
|
||||||
@@ -1487,6 +1512,7 @@ onMounted(() => {
|
|||||||
remarkHistory.load()
|
remarkHistory.load()
|
||||||
aiQueryHistory.load()
|
aiQueryHistory.load()
|
||||||
loadAiConfig()
|
loadAiConfig()
|
||||||
|
loadExcludedDisplayStyle()
|
||||||
fetchCategories()
|
fetchCategories()
|
||||||
fetchTags()
|
fetchTags()
|
||||||
loadChannels()
|
loadChannels()
|
||||||
@@ -1688,7 +1714,7 @@ onMounted(() => {
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
:row-key="(row: TransactionItem) => row.id"
|
: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"
|
v-model:checked-row-keys="checkedTransactionIds"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package cn.rosercode.finance.config;
|
||||||
|
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存配置。
|
||||||
|
*
|
||||||
|
* <p>开启 Spring Cache 注解支持,并显式提供基于 {@link ConcurrentMapCacheManager} 的进程内缓存。
|
||||||
|
* 显式声明可避免 classpath 存在 JCache(JSR-107)API 时被自动装配选为默认缓存类型
|
||||||
|
* (无 JCache Provider 时无法动态创建缓存)。</p>
|
||||||
|
*
|
||||||
|
* @date 2026-09-21 10:20:40
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
|
public class CacheConfig {
|
||||||
|
|
||||||
|
/** 用户偏好缓存名 */
|
||||||
|
public static final String USER_PREFERENCE_CACHE = "userPreference";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进程内缓存管理器。
|
||||||
|
*
|
||||||
|
* @return 基于 ConcurrentHashMap 的缓存管理器
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public CacheManager cacheManager() {
|
||||||
|
return new ConcurrentMapCacheManager(USER_PREFERENCE_CACHE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
package cn.rosercode.finance.config.app;
|
package cn.rosercode.finance.config.app;
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@@ -21,9 +18,6 @@ public class AppConfig {
|
|||||||
/** 默认日志保留天数 */
|
/** 默认日志保留天数 */
|
||||||
public static final int DEFAULT_LOG_RETENTION_DAYS = 30;
|
public static final int DEFAULT_LOG_RETENTION_DAYS = 30;
|
||||||
|
|
||||||
/** 系统默认计入统计的账单来源,其余来源默认不计入 */
|
|
||||||
private static final Set<String> DEFAULT_INCLUDED_SOURCES = Set.of("WECHAT", "ALIPAY");
|
|
||||||
|
|
||||||
/** 是否开放注册:true 开放,false 关闭(默认关闭) */
|
/** 是否开放注册:true 开放,false 关闭(默认关闭) */
|
||||||
private boolean registrationEnabled = false;
|
private boolean registrationEnabled = false;
|
||||||
|
|
||||||
@@ -32,23 +26,4 @@ public class AppConfig {
|
|||||||
|
|
||||||
/** 日志保留天数(访问日志/登录日志),0 或负数表示永久保留,默认 30 天 */
|
/** 日志保留天数(访问日志/登录日志),0 或负数表示永久保留,默认 30 天 */
|
||||||
private Integer logRetentionDays = DEFAULT_LOG_RETENTION_DAYS;
|
private Integer logRetentionDays = DEFAULT_LOG_RETENTION_DAYS;
|
||||||
|
|
||||||
/** 按账单来源的「计入统计」默认值,key 为账单来源编码(BillType 名称);为空或缺少某来源时使用系统默认 */
|
|
||||||
private Map<String, Boolean> billSourceIncludeInStats;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析指定账单来源的「计入统计」默认值。
|
|
||||||
*
|
|
||||||
* <p>优先取用户配置;未配置时,微信与支付宝默认计入,其余来源默认不计入。</p>
|
|
||||||
*
|
|
||||||
* @param billType 账单来源编码
|
|
||||||
* @return 是否默认计入统计
|
|
||||||
*/
|
|
||||||
public boolean resolveIncludeInStats(String billType) {
|
|
||||||
if (billType != null && billSourceIncludeInStats != null
|
|
||||||
&& billSourceIncludeInStats.containsKey(billType)) {
|
|
||||||
return Boolean.TRUE.equals(billSourceIncludeInStats.get(billType));
|
|
||||||
}
|
|
||||||
return billType != null && DEFAULT_INCLUDED_SOURCES.contains(billType);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package cn.rosercode.finance.controller;
|
package cn.rosercode.finance.controller;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -14,10 +11,8 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import cn.rosercode.finance.common.Result;
|
import cn.rosercode.finance.common.Result;
|
||||||
import cn.rosercode.finance.config.app.AppConfig;
|
import cn.rosercode.finance.config.app.AppConfig;
|
||||||
import cn.rosercode.finance.config.app.AppConfigStore;
|
import cn.rosercode.finance.config.app.AppConfigStore;
|
||||||
import cn.rosercode.finance.dto.BillSourceStatsRequest;
|
|
||||||
import cn.rosercode.finance.dto.LogRetentionRequest;
|
import cn.rosercode.finance.dto.LogRetentionRequest;
|
||||||
import cn.rosercode.finance.dto.NotificationUrlRequest;
|
import cn.rosercode.finance.dto.NotificationUrlRequest;
|
||||||
import cn.rosercode.finance.enums.BillType;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用配置控制器
|
* 应用配置控制器
|
||||||
@@ -90,55 +85,4 @@ public class AppConfigController {
|
|||||||
appConfigStore.save(config);
|
appConfigStore.save(config);
|
||||||
return Result.success(config.getLogRetentionDays());
|
return Result.success(config.getLogRetentionDays());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询按账单来源的「计入统计」默认值。
|
|
||||||
*
|
|
||||||
* @return 账单来源编码到是否计入统计的映射,包含全部支持的来源
|
|
||||||
*/
|
|
||||||
@GetMapping("/bill-source-include-in-stats")
|
|
||||||
@Operation(summary = "查询账单来源统计默认值", description = "返回各账单来源导入时「是否计入统计」的默认值")
|
|
||||||
public Result<Map<String, Boolean>> getBillSourceIncludeInStats() {
|
|
||||||
return Result.success(effectiveBillSourceStats(appConfigStore.load()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新按账单来源的「计入统计」默认值。
|
|
||||||
*
|
|
||||||
* @param request 各账单来源的默认值
|
|
||||||
* @return 更新后的有效映射
|
|
||||||
*/
|
|
||||||
@PutMapping("/bill-source-include-in-stats")
|
|
||||||
@Operation(summary = "更新账单来源统计默认值", description = "设置各账单来源导入时「是否计入统计」的默认值")
|
|
||||||
public Result<Map<String, Boolean>> updateBillSourceIncludeInStats(@RequestBody BillSourceStatsRequest request) {
|
|
||||||
AppConfig config = appConfigStore.load();
|
|
||||||
Map<String, Boolean> defaults = new LinkedHashMap<>();
|
|
||||||
Map<String, Boolean> requested = request.getDefaults();
|
|
||||||
if (requested != null) {
|
|
||||||
// 仅保留系统支持的账单来源,忽略非法键
|
|
||||||
for (BillType type : BillType.values()) {
|
|
||||||
Boolean value = requested.get(type.name());
|
|
||||||
if (value != null) {
|
|
||||||
defaults.put(type.name(), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.setBillSourceIncludeInStats(defaults);
|
|
||||||
appConfigStore.save(config);
|
|
||||||
return Result.success(effectiveBillSourceStats(config));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建包含全部账单来源的有效默认值映射。
|
|
||||||
*
|
|
||||||
* @param config 应用配置
|
|
||||||
* @return 账单来源编码到是否计入统计的映射
|
|
||||||
*/
|
|
||||||
private Map<String, Boolean> effectiveBillSourceStats(AppConfig config) {
|
|
||||||
Map<String, Boolean> result = new LinkedHashMap<>();
|
|
||||||
for (BillType type : BillType.values()) {
|
|
||||||
result.put(type.name(), config.resolveIncludeInStats(type.name()));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package cn.rosercode.finance.controller;
|
package cn.rosercode.finance.controller;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -21,6 +24,7 @@ import cn.rosercode.finance.service.BillImportService;
|
|||||||
import cn.rosercode.finance.util.UserContext;
|
import cn.rosercode.finance.util.UserContext;
|
||||||
import cn.rosercode.finance.vo.BillImportResultVO;
|
import cn.rosercode.finance.vo.BillImportResultVO;
|
||||||
import cn.rosercode.finance.vo.BillParseResultVO;
|
import cn.rosercode.finance.vo.BillParseResultVO;
|
||||||
|
import cn.rosercode.finance.vo.BillTypeVO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账单导入控制器
|
* 账单导入控制器
|
||||||
@@ -40,6 +44,24 @@ public class BillImportController {
|
|||||||
this.billImportService = billImportService;
|
this.billImportService = billImportService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统支持的账单类型及其文件扩展名
|
||||||
|
*
|
||||||
|
* @return 账单类型列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/types")
|
||||||
|
@Operation(summary = "获取账单类型列表", description = "返回系统支持的账单来源编码与允许的账单文件扩展名")
|
||||||
|
public Result<List<BillTypeVO>> types() {
|
||||||
|
List<BillTypeVO> types = new ArrayList<>();
|
||||||
|
for (BillType billType : BillType.values()) {
|
||||||
|
BillTypeVO vo = new BillTypeVO();
|
||||||
|
vo.setCode(billType.name());
|
||||||
|
vo.setExtensions(billType.getExtensions());
|
||||||
|
types.add(vo);
|
||||||
|
}
|
||||||
|
return Result.success(types);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析账单文件(不落库)
|
* 解析账单文件(不落库)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package cn.rosercode.finance.controller;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
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.RestController;
|
||||||
|
|
||||||
|
import cn.rosercode.finance.common.Result;
|
||||||
|
import cn.rosercode.finance.dto.BillSourceStatsRequest;
|
||||||
|
import cn.rosercode.finance.dto.ExcludedDisplayStyleRequest;
|
||||||
|
import cn.rosercode.finance.service.UserPreferenceService;
|
||||||
|
import cn.rosercode.finance.util.UserContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户设置控制器
|
||||||
|
*
|
||||||
|
* <p>提供按当前登录用户存储的偏好配置读写接口,包含账单来源统计默认值与
|
||||||
|
* 「不计入统计」记录的展示样式。</p>
|
||||||
|
*
|
||||||
|
* @date 2026-09-21 10:14:12
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/setting")
|
||||||
|
@Tag(name = "用户设置", description = "用户级偏好配置读写接口")
|
||||||
|
public class UserSettingController {
|
||||||
|
|
||||||
|
private final UserPreferenceService userPreferenceService;
|
||||||
|
|
||||||
|
public UserSettingController(UserPreferenceService userPreferenceService) {
|
||||||
|
this.userPreferenceService = userPreferenceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前用户按账单来源的「计入统计」默认值。
|
||||||
|
*
|
||||||
|
* @return 账单来源编码到是否计入统计的映射,包含全部支持的来源
|
||||||
|
*/
|
||||||
|
@GetMapping("/bill-source-include-in-stats")
|
||||||
|
@Operation(summary = "查询账单来源统计默认值", description = "返回当前用户各账单来源导入时「是否计入统计」的默认值")
|
||||||
|
public Result<Map<String, Boolean>> getBillSourceIncludeInStats() {
|
||||||
|
return Result.success(userPreferenceService.getBillSourceIncludeInStats(UserContext.getUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新当前用户按账单来源的「计入统计」默认值。
|
||||||
|
*
|
||||||
|
* @param request 各账单来源的默认值
|
||||||
|
* @return 更新后的有效映射
|
||||||
|
*/
|
||||||
|
@PutMapping("/bill-source-include-in-stats")
|
||||||
|
@Operation(summary = "更新账单来源统计默认值", description = "设置当前用户各账单来源导入时「是否计入统计」的默认值")
|
||||||
|
public Result<Map<String, Boolean>> updateBillSourceIncludeInStats(@RequestBody BillSourceStatsRequest request) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
userPreferenceService.saveBillSourceIncludeInStats(userId, request.getDefaults());
|
||||||
|
return Result.success(userPreferenceService.getBillSourceIncludeInStats(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前用户「不计入统计」记录的展示样式。
|
||||||
|
*
|
||||||
|
* @return 展示样式:strikethrough-删除线、muted-灰色标签
|
||||||
|
*/
|
||||||
|
@GetMapping("/excluded-display-style")
|
||||||
|
@Operation(summary = "查询不计入统计展示样式", description = "读取当前用户收支记录中「不计入统计」记录使用的展示样式")
|
||||||
|
public Result<String> getExcludedDisplayStyle() {
|
||||||
|
return Result.success(userPreferenceService.getExcludedDisplayStyle(UserContext.getUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新当前用户「不计入统计」记录的展示样式。
|
||||||
|
*
|
||||||
|
* @param request 展示样式更新请求
|
||||||
|
* @return 更新后的展示样式,取值非法时回退默认值
|
||||||
|
*/
|
||||||
|
@PutMapping("/excluded-display-style")
|
||||||
|
@Operation(summary = "更新不计入统计展示样式", description = "设置当前用户收支记录中「不计入统计」记录使用的展示样式")
|
||||||
|
public Result<String> updateExcludedDisplayStyle(@RequestBody ExcludedDisplayStyleRequest request) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
userPreferenceService.saveExcludedDisplayStyle(userId, request.getStyle());
|
||||||
|
return Result.success(userPreferenceService.getExcludedDisplayStyle(userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package cn.rosercode.finance.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「不计入统计」记录展示样式更新请求对象。
|
||||||
|
*
|
||||||
|
* @date 2026-09-21 09:36:10
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ExcludedDisplayStyleRequest {
|
||||||
|
|
||||||
|
/** 展示样式:strikethrough-删除线、muted-灰色标签 */
|
||||||
|
private String style;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import java.io.IOException;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
@@ -19,6 +20,7 @@ import org.springframework.stereotype.Component;
|
|||||||
import cn.rosercode.finance.entity.EmailBillConfig;
|
import cn.rosercode.finance.entity.EmailBillConfig;
|
||||||
import cn.rosercode.finance.entity.EmailBillMessage;
|
import cn.rosercode.finance.entity.EmailBillMessage;
|
||||||
import cn.rosercode.finance.entity.EmailBillSenderRule;
|
import cn.rosercode.finance.entity.EmailBillSenderRule;
|
||||||
|
import cn.rosercode.finance.enums.BillType;
|
||||||
import cn.rosercode.finance.enums.EmailBillStatus;
|
import cn.rosercode.finance.enums.EmailBillStatus;
|
||||||
import cn.rosercode.finance.mapper.EmailBillMessageMapper;
|
import cn.rosercode.finance.mapper.EmailBillMessageMapper;
|
||||||
import cn.rosercode.finance.service.EmailBillSenderRuleService;
|
import cn.rosercode.finance.service.EmailBillSenderRuleService;
|
||||||
@@ -35,8 +37,22 @@ import cn.rosercode.finance.service.EmailBillSenderRuleService;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class EmailBillProcessor {
|
public class EmailBillProcessor {
|
||||||
|
|
||||||
/** 支持的账单附件扩展名 */
|
/** 支持的账单附件扩展名:各账单来源扩展名的并集,另含压缩包 */
|
||||||
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of(".csv", ".xlsx", ".xls", ".pdf", ".zip");
|
private static final Set<String> SUPPORTED_EXTENSIONS = buildSupportedExtensions();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由账单来源枚举汇总支持的附件扩展名,并补充压缩包扩展名。
|
||||||
|
*
|
||||||
|
* @return 支持的扩展名集合(小写,含点)
|
||||||
|
*/
|
||||||
|
private static Set<String> buildSupportedExtensions() {
|
||||||
|
Set<String> extensions = new HashSet<>();
|
||||||
|
for (BillType type : BillType.values()) {
|
||||||
|
extensions.addAll(type.getExtensions());
|
||||||
|
}
|
||||||
|
extensions.add(".zip");
|
||||||
|
return Set.copyOf(extensions);
|
||||||
|
}
|
||||||
|
|
||||||
private final EmailBillMessageMapper messageMapper;
|
private final EmailBillMessageMapper messageMapper;
|
||||||
private final EmailBillSenderRuleService senderRuleService;
|
private final EmailBillSenderRuleService senderRuleService;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package cn.rosercode.finance.entity;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户偏好实体类,对应数据库表 t_user_preference,以键值形式存储每个用户的偏好配置。
|
||||||
|
*
|
||||||
|
* @date 2026-09-21 10:14:12
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("t_user_preference")
|
||||||
|
public class UserPreference {
|
||||||
|
|
||||||
|
/** 偏好ID,主键自增 */
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 创建时间 */
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** 更新时间 */
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** 所属用户ID */
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/** 偏好键,如 billSourceIncludeInStats / excludedDisplayStyle */
|
||||||
|
private String prefKey;
|
||||||
|
|
||||||
|
/** 偏好值,标量字符串或 JSON */
|
||||||
|
private String prefValue;
|
||||||
|
}
|
||||||
@@ -1,34 +1,41 @@
|
|||||||
package cn.rosercode.finance.enums;
|
package cn.rosercode.finance.enums;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账单类型枚举,标识支持的第三方账单来源。
|
* 账单类型枚举,标识支持的第三方账单来源。
|
||||||
*
|
*
|
||||||
* <p>当前支持支付宝、微信支付、美团、招商银行与交通银行账单,后续可扩展其他银行等账单类型。</p>
|
* <p>当前支持支付宝、微信支付、美团、招商银行与交通银行账单,后续可扩展其他银行等账单类型。
|
||||||
|
* 各来源支持的账单文件扩展名(小写、含点)随枚举一并维护,供解析与上传校验复用。</p>
|
||||||
*
|
*
|
||||||
* @date 2026-09-17 09:49:43
|
* @date 2026-09-17 09:49:43
|
||||||
*/
|
*/
|
||||||
public enum BillType {
|
public enum BillType {
|
||||||
|
|
||||||
/** 支付宝账单 */
|
/** 支付宝账单 */
|
||||||
ALIPAY("支付宝"),
|
ALIPAY("支付宝", ".csv"),
|
||||||
|
|
||||||
/** 微信支付账单 */
|
/** 微信支付账单 */
|
||||||
WECHAT("微信"),
|
WECHAT("微信", ".xlsx"),
|
||||||
|
|
||||||
/** 美团账单 */
|
/** 美团账单 */
|
||||||
MEITUAN("美团"),
|
MEITUAN("美团", ".csv"),
|
||||||
|
|
||||||
/** 招商银行账单 */
|
/** 招商银行账单(支持 CSV 交易记录与 PDF 交易流水) */
|
||||||
CMB("招商银行"),
|
CMB("招商银行", ".csv", ".pdf"),
|
||||||
|
|
||||||
/** 交通银行账单 */
|
/** 交通银行账单 */
|
||||||
BOCOM("交通银行");
|
BOCOM("交通银行", ".xls");
|
||||||
|
|
||||||
/** 账单类型的中文名称 */
|
/** 账单类型的中文名称 */
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
BillType(String label) {
|
/** 该来源支持的账单文件扩展名(小写,含点),按优先级排序 */
|
||||||
|
private final List<String> extensions;
|
||||||
|
|
||||||
|
BillType(String label, String... extensions) {
|
||||||
this.label = label;
|
this.label = label;
|
||||||
|
this.extensions = List.of(extensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,4 +46,13 @@ public enum BillType {
|
|||||||
public String getLabel() {
|
public String getLabel() {
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取该来源支持的账单文件扩展名
|
||||||
|
*
|
||||||
|
* @return 扩展名列表(小写,含点)
|
||||||
|
*/
|
||||||
|
public List<String> getExtensions() {
|
||||||
|
return extensions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package cn.rosercode.finance.enums;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易渠道枚举,统一维护系统支持的交易渠道编码与中文名称。
|
||||||
|
*
|
||||||
|
* <p>覆盖账单导入来源(支付宝、微信、美团、招商银行、交通银行)与人工记账渠道(现金),
|
||||||
|
* 供交易查询、账单导入与 AI 解析等场景复用,避免各处硬编码渠道列表导致口径不一致。</p>
|
||||||
|
*
|
||||||
|
* @date 2026-09-21 10:10:11
|
||||||
|
*/
|
||||||
|
public enum ChannelType {
|
||||||
|
|
||||||
|
/** 支付宝 */
|
||||||
|
ALIPAY("支付宝"),
|
||||||
|
|
||||||
|
/** 微信支付 */
|
||||||
|
WECHAT("微信"),
|
||||||
|
|
||||||
|
/** 美团 */
|
||||||
|
MEITUAN("美团"),
|
||||||
|
|
||||||
|
/** 招商银行 */
|
||||||
|
CMB("招商银行"),
|
||||||
|
|
||||||
|
/** 交通银行 */
|
||||||
|
BOCOM("交通银行"),
|
||||||
|
|
||||||
|
/** 现金(人工记账) */
|
||||||
|
CASH("现金");
|
||||||
|
|
||||||
|
/** 渠道中文名称 */
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
ChannelType(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取渠道中文名称。
|
||||||
|
*
|
||||||
|
* @return 中文名称
|
||||||
|
*/
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全部合法渠道编码列表,顺序与枚举声明一致。
|
||||||
|
*
|
||||||
|
* @return 渠道编码列表
|
||||||
|
*/
|
||||||
|
public static List<String> codes() {
|
||||||
|
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法渠道编码。
|
||||||
|
*
|
||||||
|
* @param code 渠道编码
|
||||||
|
* @return 合法返回 true
|
||||||
|
*/
|
||||||
|
public static boolean isValid(String code) {
|
||||||
|
return code != null && Arrays.stream(values()).anyMatch(type -> type.name().equals(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将渠道编码转换为中文名称,未知编码原样返回。
|
||||||
|
*
|
||||||
|
* @param code 渠道编码
|
||||||
|
* @return 中文名称
|
||||||
|
*/
|
||||||
|
public static String labelOf(String code) {
|
||||||
|
if (code == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
for (ChannelType type : values()) {
|
||||||
|
if (type.name().equals(code)) {
|
||||||
|
return type.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接「编码(名称)」形式的渠道说明,供 AI 提示词等场景使用。
|
||||||
|
*
|
||||||
|
* @return 以顿号分隔的渠道说明
|
||||||
|
*/
|
||||||
|
public static String describe() {
|
||||||
|
return Arrays.stream(values())
|
||||||
|
.map(type -> type.name() + "(" + type.label + ")")
|
||||||
|
.collect(Collectors.joining("、"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package cn.rosercode.finance.enums;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计口径枚举,定义统计时对「不计入统计」记录的处理方式。
|
||||||
|
*
|
||||||
|
* <p>供统计查询与 AI 统计解析等场景复用,避免各处硬编码口径列表。</p>
|
||||||
|
*
|
||||||
|
* @date 2026-09-21 10:19:51
|
||||||
|
*/
|
||||||
|
public enum StatisticsScope {
|
||||||
|
|
||||||
|
/** 仅计入统计(默认) */
|
||||||
|
INCLUDED("仅计入统计"),
|
||||||
|
|
||||||
|
/** 全部记录 */
|
||||||
|
ALL("全部"),
|
||||||
|
|
||||||
|
/** 仅不计入统计 */
|
||||||
|
EXCLUDED("仅不计入统计");
|
||||||
|
|
||||||
|
/** 口径中文名称 */
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
StatisticsScope(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取口径中文名称。
|
||||||
|
*
|
||||||
|
* @return 中文名称
|
||||||
|
*/
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全部合法口径编码列表,顺序与枚举声明一致。
|
||||||
|
*
|
||||||
|
* @return 口径编码列表
|
||||||
|
*/
|
||||||
|
public static List<String> codes() {
|
||||||
|
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法口径编码。
|
||||||
|
*
|
||||||
|
* @param code 口径编码
|
||||||
|
* @return 合法返回 true
|
||||||
|
*/
|
||||||
|
public static boolean isValid(String code) {
|
||||||
|
return code != null && Arrays.stream(values()).anyMatch(scope -> scope.name().equals(code));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package cn.rosercode.finance.enums;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收支类型枚举,统一维护交易记录的收支方向编码与中文名称。
|
||||||
|
*
|
||||||
|
* <p>供 AI 解析、规则校验与账单导入等场景复用,避免各处硬编码类型列表导致口径不一致。</p>
|
||||||
|
*
|
||||||
|
* @date 2026-09-21 10:18:45
|
||||||
|
*/
|
||||||
|
public enum TransactionType {
|
||||||
|
|
||||||
|
/** 收入 */
|
||||||
|
INCOME("收入"),
|
||||||
|
|
||||||
|
/** 支出 */
|
||||||
|
EXPENSE("支出");
|
||||||
|
|
||||||
|
/** 类型中文名称 */
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
TransactionType(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取类型中文名称。
|
||||||
|
*
|
||||||
|
* @return 中文名称
|
||||||
|
*/
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全部合法类型编码列表,顺序与枚举声明一致。
|
||||||
|
*
|
||||||
|
* @return 类型编码列表
|
||||||
|
*/
|
||||||
|
public static List<String> codes() {
|
||||||
|
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法类型编码。
|
||||||
|
*
|
||||||
|
* @param code 类型编码
|
||||||
|
* @return 合法返回 true
|
||||||
|
*/
|
||||||
|
public static boolean isValid(String code) {
|
||||||
|
return code != null && Arrays.stream(values()).anyMatch(type -> type.name().equals(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将类型编码转换为中文名称,未知编码原样返回。
|
||||||
|
*
|
||||||
|
* @param code 类型编码
|
||||||
|
* @return 中文名称
|
||||||
|
*/
|
||||||
|
public static String labelOf(String code) {
|
||||||
|
if (code == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
for (TransactionType type : values()) {
|
||||||
|
if (type.name().equals(code)) {
|
||||||
|
return type.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接「编码(名称)」形式的类型说明,供 AI 提示词等场景使用。
|
||||||
|
*
|
||||||
|
* @return 以顿号分隔的类型说明
|
||||||
|
*/
|
||||||
|
public static String describe() {
|
||||||
|
return Arrays.stream(values())
|
||||||
|
.map(type -> type.name() + "(" + type.label + ")")
|
||||||
|
.collect(Collectors.joining("、"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package cn.rosercode.finance.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
import cn.rosercode.finance.entity.UserPreference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户偏好 Mapper 接口,继承 MyBatis-Plus 的 BaseMapper,提供用户偏好表的基础 CRUD 操作。
|
||||||
|
*
|
||||||
|
* @date 2026-09-21 10:14:12
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface UserPreferenceMapper extends BaseMapper<UserPreference> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package cn.rosercode.finance.service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户偏好服务接口,提供按用户存储的偏好配置读写。
|
||||||
|
*
|
||||||
|
* @date 2026-09-21 10:14:12
|
||||||
|
*/
|
||||||
|
public interface UserPreferenceService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户按账单来源的「计入统计」默认值,未配置的来源回退系统默认。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 账单来源编码到是否计入统计的映射,包含全部支持的来源
|
||||||
|
*/
|
||||||
|
Map<String, Boolean> getBillSourceIncludeInStats(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存当前用户按账单来源的「计入统计」默认值,仅保留系统支持的来源。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param defaults 账单来源编码到是否计入统计的映射
|
||||||
|
*/
|
||||||
|
void saveBillSourceIncludeInStats(Long userId, Map<String, Boolean> defaults);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户「不计入统计」记录的展示样式,未配置或非法时回退默认值。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 展示样式:strikethrough-删除线、muted-灰色标签
|
||||||
|
*/
|
||||||
|
String getExcludedDisplayStyle(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存当前用户「不计入统计」记录的展示样式,非法取值回退默认值。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param style 展示样式
|
||||||
|
*/
|
||||||
|
void saveExcludedDisplayStyle(Long userId, String style);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import cn.rosercode.finance.client.DeepSeekResponse;
|
|||||||
import cn.rosercode.finance.common.BusinessException;
|
import cn.rosercode.finance.common.BusinessException;
|
||||||
import cn.rosercode.finance.entity.AiConfig;
|
import cn.rosercode.finance.entity.AiConfig;
|
||||||
import cn.rosercode.finance.enums.AiCallType;
|
import cn.rosercode.finance.enums.AiCallType;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.service.AiCategoryService;
|
import cn.rosercode.finance.service.AiCategoryService;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
import cn.rosercode.finance.service.AiUsageService;
|
import cn.rosercode.finance.service.AiUsageService;
|
||||||
@@ -31,13 +32,13 @@ public class AiCategoryServiceImpl implements AiCategoryService {
|
|||||||
/** 图标最大长度(按 UTF-16 字符数限制) */
|
/** 图标最大长度(按 UTF-16 字符数限制) */
|
||||||
private static final int ICON_MAX_LENGTH = 16;
|
private static final int ICON_MAX_LENGTH = 16;
|
||||||
|
|
||||||
/** 合法的收支类型 */
|
/** 合法的收支类型,统一取自类型枚举 */
|
||||||
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
|
private static final List<String> VALID_TYPES = TransactionType.codes();
|
||||||
|
|
||||||
/** 系统提示词,要求模型严格返回 JSON */
|
/** 系统提示词,要求模型严格返回 JSON */
|
||||||
private static final String SYSTEM_PROMPT = "你是一个个人记账应用的分类助手。"
|
private static final String SYSTEM_PROMPT = "你是一个个人记账应用的分类助手。"
|
||||||
+ "请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。"
|
+ "请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。"
|
||||||
+ "要求:1. name 为 2-6 个汉字的分类名称;2. type 只能是 INCOME(收入)或 EXPENSE(支出);"
|
+ "要求:1. name 为 2-6 个汉字的分类名称;2. type 只能是 " + TransactionType.describe() + ";"
|
||||||
+ "3. icon 为与该分类最贴切的一个 Emoji;4. 只输出 JSON,不要输出任何多余内容。"
|
+ "3. icon 为与该分类最贴切的一个 Emoji;4. 只输出 JSON,不要输出任何多余内容。"
|
||||||
+ "输出格式:{\"name\":\"交通\",\"type\":\"EXPENSE\",\"icon\":\"🚌\",\"reason\":\"简要中文理由\"}";
|
+ "输出格式:{\"name\":\"交通\",\"type\":\"EXPENSE\",\"icon\":\"🚌\",\"reason\":\"简要中文理由\"}";
|
||||||
|
|
||||||
@@ -105,9 +106,9 @@ public class AiCategoryServiceImpl implements AiCategoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 类型非法时回退为支出
|
// 类型非法时回退为支出
|
||||||
String type = root.path("type").asText("EXPENSE").trim().toUpperCase();
|
String type = root.path("type").asText(TransactionType.EXPENSE.name()).trim().toUpperCase();
|
||||||
if (!VALID_TYPES.contains(type)) {
|
if (!VALID_TYPES.contains(type)) {
|
||||||
type = "EXPENSE";
|
type = TransactionType.EXPENSE.name();
|
||||||
}
|
}
|
||||||
|
|
||||||
String icon = root.path("icon").asText("").trim();
|
String icon = root.path("icon").asText("").trim();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import cn.rosercode.finance.client.DeepSeekResponse;
|
|||||||
import cn.rosercode.finance.common.BusinessException;
|
import cn.rosercode.finance.common.BusinessException;
|
||||||
import cn.rosercode.finance.entity.AiConfig;
|
import cn.rosercode.finance.entity.AiConfig;
|
||||||
import cn.rosercode.finance.enums.AiCallType;
|
import cn.rosercode.finance.enums.AiCallType;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
import cn.rosercode.finance.service.AiDecorationService;
|
import cn.rosercode.finance.service.AiDecorationService;
|
||||||
import cn.rosercode.finance.service.AiUsageService;
|
import cn.rosercode.finance.service.AiUsageService;
|
||||||
@@ -159,11 +160,11 @@ public class AiDecorationServiceImpl implements AiDecorationService {
|
|||||||
* @return 中文描述
|
* @return 中文描述
|
||||||
*/
|
*/
|
||||||
private String typeLabel(String type) {
|
private String typeLabel(String type) {
|
||||||
if ("INCOME".equalsIgnoreCase(type)) {
|
if (TransactionType.INCOME.name().equalsIgnoreCase(type)) {
|
||||||
return "收入";
|
return TransactionType.INCOME.getLabel();
|
||||||
}
|
}
|
||||||
if ("EXPENSE".equalsIgnoreCase(type)) {
|
if (TransactionType.EXPENSE.name().equalsIgnoreCase(type)) {
|
||||||
return "支出";
|
return TransactionType.EXPENSE.getLabel();
|
||||||
}
|
}
|
||||||
return "未指定";
|
return "未指定";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import cn.rosercode.finance.entity.AiConfig;
|
|||||||
import cn.rosercode.finance.entity.Category;
|
import cn.rosercode.finance.entity.Category;
|
||||||
import cn.rosercode.finance.entity.Tag;
|
import cn.rosercode.finance.entity.Tag;
|
||||||
import cn.rosercode.finance.enums.AiCallType;
|
import cn.rosercode.finance.enums.AiCallType;
|
||||||
|
import cn.rosercode.finance.enums.ChannelType;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
@@ -42,11 +44,11 @@ public class AiQueryServiceImpl implements AiQueryService {
|
|||||||
/** 日期时间格式,与前端筛选参数保持一致 */
|
/** 日期时间格式,与前端筛选参数保持一致 */
|
||||||
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
|
||||||
/** 合法的收支类型 */
|
/** 合法的收支类型,统一取自类型枚举 */
|
||||||
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
|
private static final List<String> VALID_TYPES = TransactionType.codes();
|
||||||
|
|
||||||
/** 合法的交易渠道 */
|
/** 合法的交易渠道,统一取自渠道枚举 */
|
||||||
private static final List<String> VALID_CHANNELS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
|
private static final List<String> VALID_CHANNELS = ChannelType.codes();
|
||||||
|
|
||||||
private final AiConfigService aiConfigService;
|
private final AiConfigService aiConfigService;
|
||||||
private final CategoryMapper categoryMapper;
|
private final CategoryMapper categoryMapper;
|
||||||
@@ -122,8 +124,8 @@ public class AiQueryServiceImpl implements AiQueryService {
|
|||||||
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
|
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
|
||||||
String today = LocalDate.now().format(DATE_FMT);
|
String today = LocalDate.now().format(DATE_FMT);
|
||||||
return "你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。\n"
|
return "你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。\n"
|
||||||
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\n"
|
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
|
||||||
+ "可用的交易渠道:WECHAT(微信)、ALIPAY(支付宝)、CASH(现金)、BANK(银行卡)。\n"
|
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
|
||||||
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
||||||
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
||||||
+ "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
|
+ "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import cn.rosercode.finance.entity.AiConfig;
|
|||||||
import cn.rosercode.finance.entity.Category;
|
import cn.rosercode.finance.entity.Category;
|
||||||
import cn.rosercode.finance.entity.Tag;
|
import cn.rosercode.finance.entity.Tag;
|
||||||
import cn.rosercode.finance.enums.AiCallType;
|
import cn.rosercode.finance.enums.AiCallType;
|
||||||
|
import cn.rosercode.finance.enums.ChannelType;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
@@ -39,11 +41,11 @@ public class AiRecordServiceImpl implements AiRecordService {
|
|||||||
/** 日期时间格式,与前端表单保持一致 */
|
/** 日期时间格式,与前端表单保持一致 */
|
||||||
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
|
||||||
/** 合法的收支类型 */
|
/** 合法的收支类型,统一取自类型枚举 */
|
||||||
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
|
private static final List<String> VALID_TYPES = TransactionType.codes();
|
||||||
|
|
||||||
/** 合法的交易渠道 */
|
/** 合法的交易渠道,统一取自渠道枚举 */
|
||||||
private static final List<String> VALID_CHANNELS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
|
private static final List<String> VALID_CHANNELS = ChannelType.codes();
|
||||||
|
|
||||||
private final AiConfigService aiConfigService;
|
private final AiConfigService aiConfigService;
|
||||||
private final CategoryMapper categoryMapper;
|
private final CategoryMapper categoryMapper;
|
||||||
@@ -119,8 +121,8 @@ public class AiRecordServiceImpl implements AiRecordService {
|
|||||||
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
|
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
|
||||||
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
return "你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。\n"
|
return "你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。\n"
|
||||||
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\n"
|
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
|
||||||
+ "可用的交易渠道:WECHAT(微信)、ALIPAY(支付宝)、CASH(现金)、BANK(银行卡)。\n"
|
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
|
||||||
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
||||||
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
||||||
+ "规则:1. type 默认为 EXPENSE,除非明显是收入;2. amount 为必填数字;"
|
+ "规则:1. type 默认为 EXPENSE,除非明显是收入;2. amount 为必填数字;"
|
||||||
@@ -155,8 +157,8 @@ public class AiRecordServiceImpl implements AiRecordService {
|
|||||||
vo.setSummary(root.path("summary").asText(""));
|
vo.setSummary(root.path("summary").asText(""));
|
||||||
|
|
||||||
// 类型:仅接受合法值,默认支出
|
// 类型:仅接受合法值,默认支出
|
||||||
String type = root.path("type").asText("EXPENSE").toUpperCase();
|
String type = root.path("type").asText(TransactionType.EXPENSE.name()).toUpperCase();
|
||||||
vo.setType(VALID_TYPES.contains(type) ? type : "EXPENSE");
|
vo.setType(VALID_TYPES.contains(type) ? type : TransactionType.EXPENSE.name());
|
||||||
|
|
||||||
// 金额
|
// 金额
|
||||||
vo.setAmount(decimalOrNull(root.path("amount")));
|
vo.setAmount(decimalOrNull(root.path("amount")));
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import cn.rosercode.finance.entity.Tag;
|
|||||||
import cn.rosercode.finance.entity.Transaction;
|
import cn.rosercode.finance.entity.Transaction;
|
||||||
import cn.rosercode.finance.entity.TransactionTag;
|
import cn.rosercode.finance.entity.TransactionTag;
|
||||||
import cn.rosercode.finance.enums.AiCallType;
|
import cn.rosercode.finance.enums.AiCallType;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
import cn.rosercode.finance.mapper.TransactionMapper;
|
import cn.rosercode.finance.mapper.TransactionMapper;
|
||||||
@@ -407,7 +408,7 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
if (name.length() > CATEGORY_NAME_MAX_LENGTH) {
|
if (name.length() > CATEGORY_NAME_MAX_LENGTH) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String newType = type != null ? type : "EXPENSE";
|
String newType = type != null ? type : TransactionType.EXPENSE.name();
|
||||||
category = resolveCategory(userId, newType, name,
|
category = resolveCategory(userId, newType, name,
|
||||||
truncate(item.getNewCategoryIcon(), ICON_MAX_LENGTH), categoryNameMap);
|
truncate(item.getNewCategoryIcon(), ICON_MAX_LENGTH), categoryNameMap);
|
||||||
if (type == null) {
|
if (type == null) {
|
||||||
@@ -782,7 +783,7 @@ public class AiRuleServiceImpl implements AiRuleService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String upper = type.trim().toUpperCase();
|
String upper = type.trim().toUpperCase();
|
||||||
return "INCOME".equals(upper) || "EXPENSE".equals(upper) ? upper : null;
|
return TransactionType.isValid(upper) ? upper : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.time.DayOfWeek;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -21,7 +22,10 @@ import cn.rosercode.finance.entity.AiConfig;
|
|||||||
import cn.rosercode.finance.entity.Category;
|
import cn.rosercode.finance.entity.Category;
|
||||||
import cn.rosercode.finance.entity.Tag;
|
import cn.rosercode.finance.entity.Tag;
|
||||||
import cn.rosercode.finance.enums.AiCallType;
|
import cn.rosercode.finance.enums.AiCallType;
|
||||||
import cn.rosercode.finance.enums.BillType;
|
import cn.rosercode.finance.enums.ChannelType;
|
||||||
|
import cn.rosercode.finance.enums.StatisticsDimension;
|
||||||
|
import cn.rosercode.finance.enums.StatisticsScope;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
import cn.rosercode.finance.service.AiConfigService;
|
import cn.rosercode.finance.service.AiConfigService;
|
||||||
@@ -43,26 +47,20 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
|
|||||||
/** 日期时间格式,与前端筛选参数保持一致 */
|
/** 日期时间格式,与前端筛选参数保持一致 */
|
||||||
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
|
||||||
/** 合法的收支类型 */
|
/** 合法的收支类型,统一取自类型枚举 */
|
||||||
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
|
private static final List<String> VALID_TYPES = TransactionType.codes();
|
||||||
|
|
||||||
/** 合法的统计口径 */
|
/** 合法的统计口径,统一取自口径枚举 */
|
||||||
private static final List<String> VALID_STATS_SCOPES = List.of("INCLUDED", "ALL", "EXCLUDED");
|
private static final List<String> VALID_STATS_SCOPES = StatisticsScope.codes();
|
||||||
|
|
||||||
/** 合法的统计维度(趋势粒度) */
|
/** 合法的统计维度(趋势粒度),取自统计维度枚举中的时间维度 */
|
||||||
private static final List<String> VALID_DIMENSIONS = List.of("day", "week", "month", "quarter", "year");
|
private static final List<String> VALID_DIMENSIONS = Arrays.stream(StatisticsDimension.values())
|
||||||
|
.filter(StatisticsDimension::isTime)
|
||||||
|
.map(dimension -> dimension.name().toLowerCase())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
/** 合法的交易渠道 */
|
/** 合法的交易渠道,统一取自渠道枚举 */
|
||||||
private static final List<String> VALID_CHANNELS;
|
private static final List<String> VALID_CHANNELS = ChannelType.codes();
|
||||||
|
|
||||||
static {
|
|
||||||
List<String> channels = new ArrayList<>();
|
|
||||||
for (BillType type : BillType.values()) {
|
|
||||||
channels.add(type.name());
|
|
||||||
}
|
|
||||||
channels.add("CASH");
|
|
||||||
VALID_CHANNELS = List.copyOf(channels);
|
|
||||||
}
|
|
||||||
|
|
||||||
private final AiConfigService aiConfigService;
|
private final AiConfigService aiConfigService;
|
||||||
private final CategoryMapper categoryMapper;
|
private final CategoryMapper categoryMapper;
|
||||||
@@ -138,8 +136,8 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
|
|||||||
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
|
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
|
||||||
String today = LocalDate.now().format(DATE_FMT);
|
String today = LocalDate.now().format(DATE_FMT);
|
||||||
return "你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。\n"
|
return "你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。\n"
|
||||||
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\n"
|
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
|
||||||
+ "可用的交易渠道:WECHAT(微信)、ALIPAY(支付宝)、MEITUAN(美团)、CMB(招商银行)、BOCOM(交通银行)、CASH(现金)。\n"
|
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
|
||||||
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
|
||||||
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
|
||||||
+ "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
|
+ "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import cn.rosercode.finance.entity.Tag;
|
|||||||
import cn.rosercode.finance.entity.Transaction;
|
import cn.rosercode.finance.entity.Transaction;
|
||||||
import cn.rosercode.finance.entity.TransactionTag;
|
import cn.rosercode.finance.entity.TransactionTag;
|
||||||
import cn.rosercode.finance.enums.AiCallType;
|
import cn.rosercode.finance.enums.AiCallType;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
import cn.rosercode.finance.mapper.TransactionMapper;
|
import cn.rosercode.finance.mapper.TransactionMapper;
|
||||||
@@ -213,7 +214,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public AiSuggestionVO suggestOne(Long userId, AiSuggestOneRequest request) {
|
public AiSuggestionVO suggestOne(Long userId, AiSuggestOneRequest request) {
|
||||||
if (!"INCOME".equals(request.getType()) && !"EXPENSE".equals(request.getType())) {
|
if (!TransactionType.isValid(request.getType())) {
|
||||||
throw new BusinessException("交易类型无效");
|
throw new BusinessException("交易类型无效");
|
||||||
}
|
}
|
||||||
AiConfig config = requireConfig(userId);
|
AiConfig config = requireConfig(userId);
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import cn.rosercode.finance.common.BusinessException;
|
import cn.rosercode.finance.common.BusinessException;
|
||||||
import cn.rosercode.finance.config.app.AppConfigStore;
|
|
||||||
import cn.rosercode.finance.converter.VoConverter;
|
import cn.rosercode.finance.converter.VoConverter;
|
||||||
import cn.rosercode.finance.dto.BillImportRequest;
|
import cn.rosercode.finance.dto.BillImportRequest;
|
||||||
import cn.rosercode.finance.entity.Category;
|
import cn.rosercode.finance.entity.Category;
|
||||||
@@ -28,6 +27,7 @@ import cn.rosercode.finance.entity.Tag;
|
|||||||
import cn.rosercode.finance.entity.Transaction;
|
import cn.rosercode.finance.entity.Transaction;
|
||||||
import cn.rosercode.finance.entity.TransactionTag;
|
import cn.rosercode.finance.entity.TransactionTag;
|
||||||
import cn.rosercode.finance.enums.BillType;
|
import cn.rosercode.finance.enums.BillType;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.importer.BillArchiveExtractor;
|
import cn.rosercode.finance.importer.BillArchiveExtractor;
|
||||||
import cn.rosercode.finance.importer.BillParser;
|
import cn.rosercode.finance.importer.BillParser;
|
||||||
import cn.rosercode.finance.importer.ParsedBillRow;
|
import cn.rosercode.finance.importer.ParsedBillRow;
|
||||||
@@ -39,6 +39,7 @@ import cn.rosercode.finance.service.BillImportService;
|
|||||||
import cn.rosercode.finance.service.RuleMatchContext;
|
import cn.rosercode.finance.service.RuleMatchContext;
|
||||||
import cn.rosercode.finance.service.RuleMatchResult;
|
import cn.rosercode.finance.service.RuleMatchResult;
|
||||||
import cn.rosercode.finance.service.RuleService;
|
import cn.rosercode.finance.service.RuleService;
|
||||||
|
import cn.rosercode.finance.service.UserPreferenceService;
|
||||||
import cn.rosercode.finance.vo.BillImportResultVO;
|
import cn.rosercode.finance.vo.BillImportResultVO;
|
||||||
import cn.rosercode.finance.vo.BillParseResultVO;
|
import cn.rosercode.finance.vo.BillParseResultVO;
|
||||||
import cn.rosercode.finance.vo.TagVO;
|
import cn.rosercode.finance.vo.TagVO;
|
||||||
@@ -63,7 +64,7 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
private final RuleService ruleService;
|
private final RuleService ruleService;
|
||||||
private final VoConverter voConverter;
|
private final VoConverter voConverter;
|
||||||
private final BillArchiveExtractor archiveExtractor;
|
private final BillArchiveExtractor archiveExtractor;
|
||||||
private final AppConfigStore appConfigStore;
|
private final UserPreferenceService userPreferenceService;
|
||||||
|
|
||||||
public BillImportServiceImpl(List<BillParser> parsers,
|
public BillImportServiceImpl(List<BillParser> parsers,
|
||||||
TransactionMapper transactionMapper,
|
TransactionMapper transactionMapper,
|
||||||
@@ -73,7 +74,7 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
RuleService ruleService,
|
RuleService ruleService,
|
||||||
VoConverter voConverter,
|
VoConverter voConverter,
|
||||||
BillArchiveExtractor archiveExtractor,
|
BillArchiveExtractor archiveExtractor,
|
||||||
AppConfigStore appConfigStore) {
|
UserPreferenceService userPreferenceService) {
|
||||||
// 按账单类型注册解析器,便于扩展新账单来源
|
// 按账单类型注册解析器,便于扩展新账单来源
|
||||||
for (BillParser parser : parsers) {
|
for (BillParser parser : parsers) {
|
||||||
parserMap.put(parser.getBillType(), parser);
|
parserMap.put(parser.getBillType(), parser);
|
||||||
@@ -85,7 +86,7 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
this.ruleService = ruleService;
|
this.ruleService = ruleService;
|
||||||
this.voConverter = voConverter;
|
this.voConverter = voConverter;
|
||||||
this.archiveExtractor = archiveExtractor;
|
this.archiveExtractor = archiveExtractor;
|
||||||
this.appConfigStore = appConfigStore;
|
this.userPreferenceService = userPreferenceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,7 +111,7 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
List<ParsedBillRow> rows = parser.parse(fileContent);
|
List<ParsedBillRow> rows = parser.parse(fileContent);
|
||||||
|
|
||||||
// 按账单来源的默认值覆盖「计入统计」标记
|
// 按账单来源的默认值覆盖「计入统计」标记
|
||||||
applyBillSourceStatsDefault(rows, billType);
|
applyBillSourceStatsDefault(userId, rows, billType);
|
||||||
|
|
||||||
// 批量查询已存在的交易号,用于标记重复
|
// 批量查询已存在的交易号,用于标记重复
|
||||||
List<String> externalNos = rows.stream()
|
List<String> externalNos = rows.stream()
|
||||||
@@ -145,17 +146,7 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
* @return 候选扩展名列表(小写,含点),按优先级排序
|
* @return 候选扩展名列表(小写,含点),按优先级排序
|
||||||
*/
|
*/
|
||||||
private List<String> expectedExtensions(BillType billType) {
|
private List<String> expectedExtensions(BillType billType) {
|
||||||
if (billType == BillType.WECHAT) {
|
return billType.getExtensions();
|
||||||
return List.of(".xlsx");
|
|
||||||
}
|
|
||||||
if (billType == BillType.BOCOM) {
|
|
||||||
return List.of(".xls");
|
|
||||||
}
|
|
||||||
if (billType == BillType.CMB) {
|
|
||||||
// 招商银行支持 CSV(交易记录)与 PDF(交易流水)两种导出格式
|
|
||||||
return List.of(".csv", ".pdf");
|
|
||||||
}
|
|
||||||
return List.of(".csv");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,9 +164,9 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
|
|
||||||
// 分类为可选:仅在用户选择了默认分类时才校验其归属与类型
|
// 分类为可选:仅在用户选择了默认分类时才校验其归属与类型
|
||||||
Category expenseCategory = request.getExpenseCategoryId() == null ? null
|
Category expenseCategory = request.getExpenseCategoryId() == null ? null
|
||||||
: requireCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
|
: requireCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name());
|
||||||
Category incomeCategory = request.getIncomeCategoryId() == null ? null
|
Category incomeCategory = request.getIncomeCategoryId() == null ? null
|
||||||
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
|
: requireCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name());
|
||||||
|
|
||||||
// 默认标签:仅保留属于当前用户的标签
|
// 默认标签:仅保留属于当前用户的标签
|
||||||
List<Long> tagIds = request.getTagIds() == null || request.getTagIds().isEmpty() ? List.of()
|
List<Long> tagIds = request.getTagIds() == null || request.getTagIds().isEmpty() ? List.of()
|
||||||
@@ -200,7 +191,8 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
List<RuleMatchResult> ruleMatches = ruleService.matchBatch(userId, ruleContexts);
|
List<RuleMatchResult> ruleMatches = ruleService.matchBatch(userId, ruleContexts);
|
||||||
|
|
||||||
// 请求未显式提供「计入统计」时,按账单来源默认值兜底
|
// 请求未显式提供「计入统计」时,按账单来源默认值兜底
|
||||||
boolean sourceStatsDefault = appConfigStore.load().resolveIncludeInStats(request.getBillType());
|
boolean sourceStatsDefault = Boolean.TRUE.equals(
|
||||||
|
userPreferenceService.getBillSourceIncludeInStats(userId).get(request.getBillType()));
|
||||||
|
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
int failed = 0;
|
int failed = 0;
|
||||||
@@ -214,7 +206,7 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
BillImportRequest.Item item = items.get(i);
|
BillImportRequest.Item item = items.get(i);
|
||||||
String label = StringUtils.hasText(item.getExternalNo()) ? item.getExternalNo() : "未知交易号";
|
String label = StringUtils.hasText(item.getExternalNo()) ? item.getExternalNo() : "未知交易号";
|
||||||
// 基础字段校验
|
// 基础字段校验
|
||||||
if (!"INCOME".equals(item.getType()) && !"EXPENSE".equals(item.getType())) {
|
if (!TransactionType.isValid(item.getType())) {
|
||||||
failed++;
|
failed++;
|
||||||
messages.add(label + ":交易类型无效");
|
messages.add(label + ":交易类型无效");
|
||||||
continue;
|
continue;
|
||||||
@@ -236,7 +228,7 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Category category = "INCOME".equals(item.getType()) ? incomeCategory : expenseCategory;
|
Category category = TransactionType.INCOME.name().equals(item.getType()) ? incomeCategory : expenseCategory;
|
||||||
// 未选择默认分类时置空,表示未分类;规则建议分类优先于默认分类
|
// 未选择默认分类时置空,表示未分类;规则建议分类优先于默认分类
|
||||||
Long categoryId = category != null ? category.getId() : null;
|
Long categoryId = category != null ? category.getId() : null;
|
||||||
if (ruleMatches.get(i).getCategoryId() != null) {
|
if (ruleMatches.get(i).getCategoryId() != null) {
|
||||||
@@ -474,7 +466,7 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
*/
|
*/
|
||||||
private Category requireCategory(Long userId, Long categoryId, String type) {
|
private Category requireCategory(Long userId, Long categoryId, String type) {
|
||||||
if (categoryId == null) {
|
if (categoryId == null) {
|
||||||
throw new BusinessException("请选择" + ("INCOME".equals(type) ? "收入" : "支出") + "默认分类");
|
throw new BusinessException("请选择" + TransactionType.labelOf(type) + "默认分类");
|
||||||
}
|
}
|
||||||
Category category = categoryMapper.selectById(categoryId);
|
Category category = categoryMapper.selectById(categoryId);
|
||||||
if (category == null || !category.getUserId().equals(userId)) {
|
if (category == null || !category.getUserId().equals(userId)) {
|
||||||
@@ -506,11 +498,13 @@ public class BillImportServiceImpl implements BillImportService {
|
|||||||
* <p>采用「解析器判定 && 来源默认」的语义:来源默认不计入时整批置为不计入;
|
* <p>采用「解析器判定 && 来源默认」的语义:来源默认不计入时整批置为不计入;
|
||||||
* 来源默认计入时保留解析器对「不计收支/中性」行的排除。</p>
|
* 来源默认计入时保留解析器对「不计收支/中性」行的排除。</p>
|
||||||
*
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
* @param rows 解析结果
|
* @param rows 解析结果
|
||||||
* @param billType 账单来源
|
* @param billType 账单来源
|
||||||
*/
|
*/
|
||||||
private void applyBillSourceStatsDefault(List<ParsedBillRow> rows, BillType billType) {
|
private void applyBillSourceStatsDefault(Long userId, List<ParsedBillRow> rows, BillType billType) {
|
||||||
boolean sourceDefault = appConfigStore.load().resolveIncludeInStats(billType.name());
|
boolean sourceDefault = Boolean.TRUE.equals(
|
||||||
|
userPreferenceService.getBillSourceIncludeInStats(userId).get(billType.name()));
|
||||||
for (ParsedBillRow row : rows) {
|
for (ParsedBillRow row : rows) {
|
||||||
boolean parserValue = row.getIncludeInStats() == null || row.getIncludeInStats();
|
boolean parserValue = row.getIncludeInStats() == null || row.getIncludeInStats();
|
||||||
row.setIncludeInStats(parserValue && sourceDefault);
|
row.setIncludeInStats(parserValue && sourceDefault);
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import javax.sql.DataSource;
|
|||||||
import com.zaxxer.hikari.HikariConfig;
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
import com.zaxxer.hikari.HikariDataSource;
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.cache.Cache;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import cn.rosercode.finance.common.BusinessException;
|
import cn.rosercode.finance.common.BusinessException;
|
||||||
|
import cn.rosercode.finance.config.CacheConfig;
|
||||||
import cn.rosercode.finance.config.datasource.DataSourceContextHolder;
|
import cn.rosercode.finance.config.datasource.DataSourceContextHolder;
|
||||||
import cn.rosercode.finance.config.datasource.DataSourceType;
|
import cn.rosercode.finance.config.datasource.DataSourceType;
|
||||||
import cn.rosercode.finance.config.datasource.DatabaseConfig;
|
import cn.rosercode.finance.config.datasource.DatabaseConfig;
|
||||||
@@ -49,6 +52,7 @@ public class DatabaseServiceImpl implements DatabaseService {
|
|||||||
private final DatabaseConfigStore configStore;
|
private final DatabaseConfigStore configStore;
|
||||||
private final SchemaInitializer schemaInitializer;
|
private final SchemaInitializer schemaInitializer;
|
||||||
private final SchemaMigrator schemaMigrator;
|
private final SchemaMigrator schemaMigrator;
|
||||||
|
private final CacheManager cacheManager;
|
||||||
|
|
||||||
/** 当前生效的数据库配置,未配置时为 null */
|
/** 当前生效的数据库配置,未配置时为 null */
|
||||||
private volatile DatabaseConfig currentConfig;
|
private volatile DatabaseConfig currentConfig;
|
||||||
@@ -56,11 +60,13 @@ public class DatabaseServiceImpl implements DatabaseService {
|
|||||||
public DatabaseServiceImpl(DynamicDataSource dynamicDataSource,
|
public DatabaseServiceImpl(DynamicDataSource dynamicDataSource,
|
||||||
DatabaseConfigStore configStore,
|
DatabaseConfigStore configStore,
|
||||||
SchemaInitializer schemaInitializer,
|
SchemaInitializer schemaInitializer,
|
||||||
SchemaMigrator schemaMigrator) {
|
SchemaMigrator schemaMigrator,
|
||||||
|
CacheManager cacheManager) {
|
||||||
this.dynamicDataSource = dynamicDataSource;
|
this.dynamicDataSource = dynamicDataSource;
|
||||||
this.configStore = configStore;
|
this.configStore = configStore;
|
||||||
this.schemaInitializer = schemaInitializer;
|
this.schemaInitializer = schemaInitializer;
|
||||||
this.schemaMigrator = schemaMigrator;
|
this.schemaMigrator = schemaMigrator;
|
||||||
|
this.cacheManager = cacheManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,6 +120,8 @@ public class DatabaseServiceImpl implements DatabaseService {
|
|||||||
dynamicDataSource.addAndSwitch(config.getType(), newDataSource);
|
dynamicDataSource.addAndSwitch(config.getType(), newDataSource);
|
||||||
switched = true;
|
switched = true;
|
||||||
currentConfig = config;
|
currentConfig = config;
|
||||||
|
// 切换数据源后清空偏好缓存,避免读到旧库的数据
|
||||||
|
evictUserPreferenceCache();
|
||||||
log.info("数据库配置已更新并切换到 {}:{}", config.getType().getDisplayName(), config.maskedSummary());
|
log.info("数据库配置已更新并切换到 {}:{}", config.getType().getDisplayName(), config.maskedSummary());
|
||||||
return buildStatus(config);
|
return buildStatus(config);
|
||||||
} catch (BusinessException e) {
|
} catch (BusinessException e) {
|
||||||
@@ -187,6 +195,8 @@ public class DatabaseServiceImpl implements DatabaseService {
|
|||||||
dynamicDataSource.addAndSwitch(config.getType(), dataSource);
|
dynamicDataSource.addAndSwitch(config.getType(), dataSource);
|
||||||
switched = true;
|
switched = true;
|
||||||
currentConfig = config;
|
currentConfig = config;
|
||||||
|
// 切换数据源后清空偏好缓存,避免读到旧库的数据
|
||||||
|
evictUserPreferenceCache();
|
||||||
} finally {
|
} finally {
|
||||||
if (!switched) {
|
if (!switched) {
|
||||||
dataSource.close();
|
dataSource.close();
|
||||||
@@ -194,6 +204,16 @@ public class DatabaseServiceImpl implements DatabaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空用户偏好缓存,在数据源切换后调用。
|
||||||
|
*/
|
||||||
|
private void evictUserPreferenceCache() {
|
||||||
|
Cache cache = cacheManager.getCache(CacheConfig.USER_PREFERENCE_CACHE);
|
||||||
|
if (cache != null) {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据配置创建 Hikari 连接池。
|
* 根据配置创建 Hikari 连接池。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.springframework.stereotype.Component;
|
|||||||
import cn.rosercode.finance.entity.Category;
|
import cn.rosercode.finance.entity.Category;
|
||||||
import cn.rosercode.finance.entity.EmailBillSenderRule;
|
import cn.rosercode.finance.entity.EmailBillSenderRule;
|
||||||
import cn.rosercode.finance.entity.Tag;
|
import cn.rosercode.finance.entity.Tag;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper;
|
import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper;
|
||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
@@ -98,10 +99,10 @@ public class DefaultDataInitializer {
|
|||||||
*/
|
*/
|
||||||
public void initializeForUser(Long userId) {
|
public void initializeForUser(Long userId) {
|
||||||
for (CategorySeed seed : DEFAULT_EXPENSE_CATEGORIES) {
|
for (CategorySeed seed : DEFAULT_EXPENSE_CATEGORIES) {
|
||||||
insertCategory(userId, seed, "EXPENSE");
|
insertCategory(userId, seed, TransactionType.EXPENSE.name());
|
||||||
}
|
}
|
||||||
for (CategorySeed seed : DEFAULT_INCOME_CATEGORIES) {
|
for (CategorySeed seed : DEFAULT_INCOME_CATEGORIES) {
|
||||||
insertCategory(userId, seed, "INCOME");
|
insertCategory(userId, seed, TransactionType.INCOME.name());
|
||||||
}
|
}
|
||||||
for (TagSeed seed : DEFAULT_TAGS) {
|
for (TagSeed seed : DEFAULT_TAGS) {
|
||||||
Tag tag = new Tag();
|
Tag tag = new Tag();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import cn.rosercode.finance.entity.Category;
|
|||||||
import cn.rosercode.finance.entity.EmailBillConfig;
|
import cn.rosercode.finance.entity.EmailBillConfig;
|
||||||
import cn.rosercode.finance.entity.EmailBillMessage;
|
import cn.rosercode.finance.entity.EmailBillMessage;
|
||||||
import cn.rosercode.finance.entity.Tag;
|
import cn.rosercode.finance.entity.Tag;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.EmailBillConfigMapper;
|
import cn.rosercode.finance.mapper.EmailBillConfigMapper;
|
||||||
import cn.rosercode.finance.mapper.EmailBillMessageMapper;
|
import cn.rosercode.finance.mapper.EmailBillMessageMapper;
|
||||||
@@ -285,8 +286,8 @@ public class EmailBillConfigServiceImpl implements EmailBillConfigService {
|
|||||||
* @param request 配置请求参数
|
* @param request 配置请求参数
|
||||||
*/
|
*/
|
||||||
private void validateCategories(Long userId, EmailBillConfigRequest request) {
|
private void validateCategories(Long userId, EmailBillConfigRequest request) {
|
||||||
validateCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
|
validateCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name());
|
||||||
validateCategory(userId, request.getIncomeCategoryId(), "INCOME");
|
validateCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import cn.rosercode.finance.entity.Category;
|
|||||||
import cn.rosercode.finance.entity.EmailBillSenderRule;
|
import cn.rosercode.finance.entity.EmailBillSenderRule;
|
||||||
import cn.rosercode.finance.entity.Tag;
|
import cn.rosercode.finance.entity.Tag;
|
||||||
import cn.rosercode.finance.enums.BillType;
|
import cn.rosercode.finance.enums.BillType;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper;
|
import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper;
|
||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
@@ -181,8 +182,8 @@ public class EmailBillSenderRuleServiceImpl implements EmailBillSenderRuleServic
|
|||||||
throw new BusinessException("请至少填写一个发件人匹配串");
|
throw new BusinessException("请至少填写一个发件人匹配串");
|
||||||
}
|
}
|
||||||
// 默认分类归属与类型一致性
|
// 默认分类归属与类型一致性
|
||||||
validateCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
|
validateCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name());
|
||||||
validateCategory(userId, request.getIncomeCategoryId(), "INCOME");
|
validateCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import cn.rosercode.finance.entity.Category;
|
|||||||
import cn.rosercode.finance.entity.Rule;
|
import cn.rosercode.finance.entity.Rule;
|
||||||
import cn.rosercode.finance.entity.RuleTag;
|
import cn.rosercode.finance.entity.RuleTag;
|
||||||
import cn.rosercode.finance.entity.Tag;
|
import cn.rosercode.finance.entity.Tag;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.RuleMapper;
|
import cn.rosercode.finance.mapper.RuleMapper;
|
||||||
import cn.rosercode.finance.mapper.RuleTagMapper;
|
import cn.rosercode.finance.mapper.RuleTagMapper;
|
||||||
@@ -325,7 +326,7 @@ public class RuleServiceImpl implements RuleService {
|
|||||||
private void validate(Long userId, RuleRequest request) {
|
private void validate(Long userId, RuleRequest request) {
|
||||||
// 收支类型白名单
|
// 收支类型白名单
|
||||||
if (StringUtils.hasText(request.getType())
|
if (StringUtils.hasText(request.getType())
|
||||||
&& !"INCOME".equals(request.getType()) && !"EXPENSE".equals(request.getType())) {
|
&& !TransactionType.isValid(request.getType())) {
|
||||||
throw new BusinessException("收支类型无效");
|
throw new BusinessException("收支类型无效");
|
||||||
}
|
}
|
||||||
// 金额区间
|
// 金额区间
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import cn.rosercode.finance.dto.StatisticsQuery;
|
|||||||
import cn.rosercode.finance.dto.StatisticsRangeQuery;
|
import cn.rosercode.finance.dto.StatisticsRangeQuery;
|
||||||
import cn.rosercode.finance.entity.Category;
|
import cn.rosercode.finance.entity.Category;
|
||||||
import cn.rosercode.finance.entity.Transaction;
|
import cn.rosercode.finance.entity.Transaction;
|
||||||
|
import cn.rosercode.finance.enums.ChannelType;
|
||||||
import cn.rosercode.finance.enums.StatisticsDimension;
|
import cn.rosercode.finance.enums.StatisticsDimension;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.TransactionMapper;
|
import cn.rosercode.finance.mapper.TransactionMapper;
|
||||||
import cn.rosercode.finance.service.StatisticsService;
|
import cn.rosercode.finance.service.StatisticsService;
|
||||||
@@ -142,7 +144,7 @@ public class StatisticsServiceImpl implements StatisticsService {
|
|||||||
|
|
||||||
// 最大单笔支出
|
// 最大单笔支出
|
||||||
QueryWrapper<Transaction> maxWrapper = baseWrapper(userId, query);
|
QueryWrapper<Transaction> maxWrapper = baseWrapper(userId, query);
|
||||||
maxWrapper.eq("type", "EXPENSE").orderByDesc("amount").last("LIMIT 1");
|
maxWrapper.eq("type", TransactionType.EXPENSE.name()).orderByDesc("amount").last("LIMIT 1");
|
||||||
Transaction maxExpense = transactionMapper.selectOne(maxWrapper);
|
Transaction maxExpense = transactionMapper.selectOne(maxWrapper);
|
||||||
|
|
||||||
int dayCount = resolveDayCount(userId, query);
|
int dayCount = resolveDayCount(userId, query);
|
||||||
@@ -354,11 +356,11 @@ public class StatisticsServiceImpl implements StatisticsService {
|
|||||||
vo.setName(category != null ? category.getName() : "未分类");
|
vo.setName(category != null ? category.getName() : "未分类");
|
||||||
vo.setIcon(category != null ? category.getIcon() : "");
|
vo.setIcon(category != null ? category.getIcon() : "");
|
||||||
vo.setType(category != null ? category.getType()
|
vo.setType(category != null ? category.getType()
|
||||||
: (income.compareTo(expense) >= 0 ? "INCOME" : "EXPENSE"));
|
: (income.compareTo(expense) >= 0 ? TransactionType.INCOME.name() : TransactionType.EXPENSE.name()));
|
||||||
}
|
}
|
||||||
case CHANNEL -> vo.setName(channelLabel(key));
|
case CHANNEL -> vo.setName(channelLabel(key));
|
||||||
case TYPE -> {
|
case TYPE -> {
|
||||||
vo.setName("INCOME".equals(key) ? "收入" : "支出");
|
vo.setName(TransactionType.labelOf(key));
|
||||||
vo.setType(key);
|
vo.setType(key);
|
||||||
}
|
}
|
||||||
default -> vo.setName(key);
|
default -> vo.setName(key);
|
||||||
@@ -448,13 +450,9 @@ public class StatisticsServiceImpl implements StatisticsService {
|
|||||||
if (!StringUtils.hasText(code)) {
|
if (!StringUtils.hasText(code)) {
|
||||||
return "未填写";
|
return "未填写";
|
||||||
}
|
}
|
||||||
return switch (code) {
|
// 兼容历史 BANK 渠道
|
||||||
case "WECHAT" -> "微信";
|
if ("BANK".equals(code)) return "银行卡";
|
||||||
case "ALIPAY" -> "支付宝";
|
return ChannelType.labelOf(code);
|
||||||
case "CASH" -> "现金";
|
|
||||||
case "BANK" -> "银行卡";
|
|
||||||
default -> code;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import cn.rosercode.finance.entity.Category;
|
|||||||
import cn.rosercode.finance.entity.Tag;
|
import cn.rosercode.finance.entity.Tag;
|
||||||
import cn.rosercode.finance.entity.Transaction;
|
import cn.rosercode.finance.entity.Transaction;
|
||||||
import cn.rosercode.finance.entity.TransactionTag;
|
import cn.rosercode.finance.entity.TransactionTag;
|
||||||
import cn.rosercode.finance.enums.BillType;
|
import cn.rosercode.finance.enums.ChannelType;
|
||||||
|
import cn.rosercode.finance.enums.TransactionType;
|
||||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||||
import cn.rosercode.finance.mapper.TagMapper;
|
import cn.rosercode.finance.mapper.TagMapper;
|
||||||
import cn.rosercode.finance.mapper.TransactionMapper;
|
import cn.rosercode.finance.mapper.TransactionMapper;
|
||||||
@@ -344,9 +345,9 @@ public class TransactionServiceImpl implements TransactionService {
|
|||||||
|
|
||||||
// 分类与计入统计均为可选,仅在提供分类时校验归属与类型
|
// 分类与计入统计均为可选,仅在提供分类时校验归属与类型
|
||||||
Category expenseCategory = request.getExpenseCategoryId() == null ? null
|
Category expenseCategory = request.getExpenseCategoryId() == null ? null
|
||||||
: requireCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
|
: requireCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name());
|
||||||
Category incomeCategory = request.getIncomeCategoryId() == null ? null
|
Category incomeCategory = request.getIncomeCategoryId() == null ? null
|
||||||
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
|
: requireCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name());
|
||||||
Boolean includeInStats = request.getIncludeInStats();
|
Boolean includeInStats = request.getIncludeInStats();
|
||||||
if (expenseCategory == null && incomeCategory == null && includeInStats == null) {
|
if (expenseCategory == null && incomeCategory == null && includeInStats == null) {
|
||||||
throw new BusinessException("请至少选择分类或计入统计");
|
throw new BusinessException("请至少选择分类或计入统计");
|
||||||
@@ -358,11 +359,11 @@ public class TransactionServiceImpl implements TransactionService {
|
|||||||
.eq(Transaction::getUserId, userId)
|
.eq(Transaction::getUserId, userId)
|
||||||
.in(Transaction::getId, ids));
|
.in(Transaction::getId, ids));
|
||||||
List<Long> expenseIds = transactions.stream()
|
List<Long> expenseIds = transactions.stream()
|
||||||
.filter(t -> "EXPENSE".equals(t.getType()))
|
.filter(t -> TransactionType.EXPENSE.name().equals(t.getType()))
|
||||||
.map(Transaction::getId)
|
.map(Transaction::getId)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
List<Long> incomeIds = transactions.stream()
|
List<Long> incomeIds = transactions.stream()
|
||||||
.filter(t -> "INCOME".equals(t.getType()))
|
.filter(t -> TransactionType.INCOME.name().equals(t.getType()))
|
||||||
.map(Transaction::getId)
|
.map(Transaction::getId)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
@@ -680,7 +681,7 @@ public class TransactionServiceImpl implements TransactionService {
|
|||||||
TransactionExportVO vo = new TransactionExportVO();
|
TransactionExportVO vo = new TransactionExportVO();
|
||||||
Boolean include = t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats();
|
Boolean include = t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats();
|
||||||
if (include) {
|
if (include) {
|
||||||
vo.setType(t.getType().equals("INCOME") ? "收入" : "支出");
|
vo.setType(TransactionType.labelOf(t.getType()));
|
||||||
} else {
|
} else {
|
||||||
vo.setType("不计入统计");
|
vo.setType("不计入统计");
|
||||||
}
|
}
|
||||||
@@ -708,16 +709,9 @@ public class TransactionServiceImpl implements TransactionService {
|
|||||||
*/
|
*/
|
||||||
private String getChannelLabel(String code) {
|
private String getChannelLabel(String code) {
|
||||||
if (code == null) return "";
|
if (code == null) return "";
|
||||||
return switch (code) {
|
// 兼容历史 BANK 渠道
|
||||||
case "WECHAT" -> "微信";
|
if ("BANK".equals(code)) return "银行卡";
|
||||||
case "ALIPAY" -> "支付宝";
|
return ChannelType.labelOf(code);
|
||||||
case "CASH" -> "现金";
|
|
||||||
case "BANK" -> "银行卡";
|
|
||||||
case "MEITUAN" -> "美团";
|
|
||||||
case "CMB" -> "招商银行";
|
|
||||||
case "BOCOM" -> "交通银行";
|
|
||||||
default -> code;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -728,12 +722,6 @@ public class TransactionServiceImpl implements TransactionService {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public List<String> listChannels() {
|
public List<String> listChannels() {
|
||||||
List<String> methods = new ArrayList<>();
|
return ChannelType.codes();
|
||||||
for (BillType type : BillType.values()) {
|
|
||||||
methods.add(type.name());
|
|
||||||
}
|
|
||||||
// 人工记账使用的交易渠道,非账单来源
|
|
||||||
methods.add("CASH");
|
|
||||||
return methods;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package cn.rosercode.finance.service.impl;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import cn.rosercode.finance.config.CacheConfig;
|
||||||
|
import cn.rosercode.finance.entity.UserPreference;
|
||||||
|
import cn.rosercode.finance.enums.BillType;
|
||||||
|
import cn.rosercode.finance.mapper.UserPreferenceMapper;
|
||||||
|
import cn.rosercode.finance.service.UserPreferenceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户偏好服务实现类,以键值形式读写每个用户的偏好配置。
|
||||||
|
*
|
||||||
|
* <p>偏好持久化在 {@code t_user_preference},未配置或取值非法时回退系统默认。</p>
|
||||||
|
*
|
||||||
|
* @date 2026-09-21 10:14:12
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class UserPreferenceServiceImpl implements UserPreferenceService {
|
||||||
|
|
||||||
|
/** 偏好键:按账单来源的「计入统计」默认值 */
|
||||||
|
private static final String KEY_BILL_SOURCE_INCLUDE_IN_STATS = "billSourceIncludeInStats";
|
||||||
|
|
||||||
|
/** 偏好键:「不计入统计」记录的展示样式 */
|
||||||
|
private static final String KEY_EXCLUDED_DISPLAY_STYLE = "excludedDisplayStyle";
|
||||||
|
|
||||||
|
/** 默认计入统计的账单来源,其余来源默认不计入 */
|
||||||
|
private static final Set<String> DEFAULT_INCLUDED_SOURCES = Set.of("WECHAT", "ALIPAY");
|
||||||
|
|
||||||
|
/** 默认的「不计入统计」记录展示样式:删除线 */
|
||||||
|
private static final String DEFAULT_EXCLUDED_DISPLAY_STYLE = "strikethrough";
|
||||||
|
|
||||||
|
/** 合法的「不计入统计」记录展示样式:删除线、灰色标签 */
|
||||||
|
private static final Set<String> VALID_EXCLUDED_DISPLAY_STYLES = Set.of("strikethrough", "muted");
|
||||||
|
|
||||||
|
private final UserPreferenceMapper userPreferenceMapper;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public UserPreferenceServiceImpl(UserPreferenceMapper userPreferenceMapper, ObjectMapper objectMapper) {
|
||||||
|
this.userPreferenceMapper = userPreferenceMapper;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* 优先取用户配置,缺失的来源回退系统默认(微信、支付宝计入)。结果按用户缓存。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Cacheable(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'billSource:' + #userId")
|
||||||
|
public Map<String, Boolean> getBillSourceIncludeInStats(Long userId) {
|
||||||
|
Map<String, Boolean> saved = parseBooleanMap(findValue(userId, KEY_BILL_SOURCE_INCLUDE_IN_STATS));
|
||||||
|
Map<String, Boolean> result = new LinkedHashMap<>();
|
||||||
|
for (BillType type : BillType.values()) {
|
||||||
|
Boolean value = saved.get(type.name());
|
||||||
|
result.put(type.name(), value != null ? value : DEFAULT_INCLUDED_SOURCES.contains(type.name()));
|
||||||
|
}
|
||||||
|
// 缓存返回同一实例,包装为不可变避免被调用方修改
|
||||||
|
return Collections.unmodifiableMap(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* 保存后失效该用户的账单来源偏好缓存。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@CacheEvict(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'billSource:' + #userId")
|
||||||
|
public void saveBillSourceIncludeInStats(Long userId, Map<String, Boolean> defaults) {
|
||||||
|
Map<String, Boolean> normalized = new LinkedHashMap<>();
|
||||||
|
if (defaults != null) {
|
||||||
|
// 仅保留系统支持的账单来源,忽略非法键
|
||||||
|
for (BillType type : BillType.values()) {
|
||||||
|
Boolean value = defaults.get(type.name());
|
||||||
|
if (value != null) {
|
||||||
|
normalized.put(type.name(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upsert(userId, KEY_BILL_SOURCE_INCLUDE_IN_STATS, toJson(normalized));
|
||||||
|
log.info("保存账单来源统计偏好: userId={}, sources={}", userId, normalized.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* 结果按用户缓存。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Cacheable(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'excludedStyle:' + #userId")
|
||||||
|
public String getExcludedDisplayStyle(Long userId) {
|
||||||
|
String value = findValue(userId, KEY_EXCLUDED_DISPLAY_STYLE);
|
||||||
|
return value != null && VALID_EXCLUDED_DISPLAY_STYLES.contains(value) ? value : DEFAULT_EXCLUDED_DISPLAY_STYLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* 保存后失效该用户的外观样式缓存。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@CacheEvict(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'excludedStyle:' + #userId")
|
||||||
|
public void saveExcludedDisplayStyle(Long userId, String style) {
|
||||||
|
String resolved = style != null && VALID_EXCLUDED_DISPLAY_STYLES.contains(style)
|
||||||
|
? style : DEFAULT_EXCLUDED_DISPLAY_STYLE;
|
||||||
|
upsert(userId, KEY_EXCLUDED_DISPLAY_STYLE, resolved);
|
||||||
|
log.info("保存不计入统计展示样式: userId={}, style={}", userId, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定用户的某个偏好值。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param key 偏好键
|
||||||
|
* @return 偏好值,未配置时返回 null
|
||||||
|
*/
|
||||||
|
private String findValue(Long userId, String key) {
|
||||||
|
UserPreference preference = userPreferenceMapper.selectOne(new LambdaQueryWrapper<UserPreference>()
|
||||||
|
.eq(UserPreference::getUserId, userId)
|
||||||
|
.eq(UserPreference::getPrefKey, key));
|
||||||
|
return preference != null ? preference.getPrefValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入指定用户的偏好值,不存在时插入、存在时更新。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param key 偏好键
|
||||||
|
* @param value 偏好值
|
||||||
|
*/
|
||||||
|
private void upsert(Long userId, String key, String value) {
|
||||||
|
UserPreference existing = userPreferenceMapper.selectOne(new LambdaQueryWrapper<UserPreference>()
|
||||||
|
.eq(UserPreference::getUserId, userId)
|
||||||
|
.eq(UserPreference::getPrefKey, key));
|
||||||
|
if (existing == null) {
|
||||||
|
UserPreference preference = new UserPreference();
|
||||||
|
preference.setUserId(userId);
|
||||||
|
preference.setPrefKey(key);
|
||||||
|
preference.setPrefValue(value);
|
||||||
|
userPreferenceMapper.insert(preference);
|
||||||
|
} else {
|
||||||
|
existing.setPrefValue(value);
|
||||||
|
userPreferenceMapper.updateById(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 字符串解析为账单来源布尔映射,解析失败时返回空映射。
|
||||||
|
*
|
||||||
|
* @param json JSON 字符串
|
||||||
|
* @return 账单来源编码到是否计入统计的映射
|
||||||
|
*/
|
||||||
|
private Map<String, Boolean> parseBooleanMap(String json) {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, new TypeReference<Map<String, Boolean>>() {
|
||||||
|
});
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.warn("解析账单来源统计偏好失败:{}", e.getMessage());
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将对象序列化为 JSON 字符串。
|
||||||
|
*
|
||||||
|
* @param value 待序列化对象
|
||||||
|
* @return JSON 字符串
|
||||||
|
*/
|
||||||
|
private String toJson(Object value) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(value);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalStateException("序列化用户偏好失败:" + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package cn.rosercode.finance.vo;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账单类型视图对象,用于向前端下发支持的账单来源及其文件扩展名。
|
||||||
|
*
|
||||||
|
* <p>名称与导出提示等文案由前端按语言维护,后端仅提供编码与允许的扩展名。</p>
|
||||||
|
*
|
||||||
|
* @date 2026-09-21 10:20:13
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class BillTypeVO {
|
||||||
|
|
||||||
|
/** 账单来源编码,对应 {@link cn.rosercode.finance.enums.BillType} 名称 */
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
/** 该来源支持的账单文件扩展名(小写,含点) */
|
||||||
|
private List<String> extensions;
|
||||||
|
}
|
||||||
@@ -260,3 +260,14 @@ CREATE TABLE IF NOT EXISTS t_email_bill_message (
|
|||||||
UNIQUE INDEX uk_email_bill_message (config_id, uid, attachment_index) COMMENT '配置+UID+附件序号唯一索引,用于去重',
|
UNIQUE INDEX uk_email_bill_message (config_id, uid, attachment_index) COMMENT '配置+UID+附件序号唯一索引,用于去重',
|
||||||
INDEX idx_email_bill_message_user_status (user_id, status) COMMENT '用户ID+状态联合索引'
|
INDEX idx_email_bill_message_user_status (user_id, status) COMMENT '用户ID+状态联合索引'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 用户偏好表:按用户存储键值形式的偏好配置
|
||||||
|
CREATE TABLE IF NOT EXISTS t_user_preference (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '偏好ID,主键自增',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||||
|
pref_key VARCHAR(50) NOT NULL COMMENT '偏好键,如 billSourceIncludeInStats / excludedDisplayStyle',
|
||||||
|
pref_value TEXT COMMENT '偏好值,标量字符串或 JSON',
|
||||||
|
UNIQUE INDEX uk_user_pref (user_id, pref_key) COMMENT '用户+偏好键唯一索引'
|
||||||
|
);
|
||||||
|
|||||||
@@ -325,3 +325,21 @@ WHEN NEW.updated_at = OLD.updated_at
|
|||||||
BEGIN
|
BEGIN
|
||||||
UPDATE t_email_bill_message SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
|
UPDATE t_email_bill_message SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
|
||||||
END;;
|
END;;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS t_user_preference (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
pref_key VARCHAR(50) NOT NULL,
|
||||||
|
pref_value TEXT
|
||||||
|
);;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_pref ON t_user_preference (user_id, pref_key);;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_user_preference_updated_at
|
||||||
|
AFTER UPDATE ON t_user_preference
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN NEW.updated_at = OLD.updated_at
|
||||||
|
BEGIN
|
||||||
|
UPDATE t_user_preference SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
|
||||||
|
END;;
|
||||||
|
|||||||
Reference in New Issue
Block a user