diff --git a/frontend/src/api/app.ts b/frontend/src/api/app.ts deleted file mode 100644 index dde918d..0000000 --- a/frontend/src/api/app.ts +++ /dev/null @@ -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) { - return request.put('/app/bill-source-include-in-stats', { defaults }) -} diff --git a/frontend/src/api/setting.ts b/frontend/src/api/setting.ts new file mode 100644 index 0000000..6f43cc7 --- /dev/null +++ b/frontend/src/api/setting.ts @@ -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) { + 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 }) +} diff --git a/frontend/src/api/transaction.ts b/frontend/src/api/transaction.ts index fbf1b49..a9907d8 100644 --- a/frontend/src/api/transaction.ts +++ b/frontend/src/api/transaction.ts @@ -77,8 +77,19 @@ export function toggleRemarkHidden(id: number, remarkHidden: boolean) { return request.put(`/transaction/${id}/remark-hidden`, { remarkHidden }) } -/** 支持的账单类型,当前支持支付宝、微信、美团、招商银行与交通银行 */ -export type BillType = 'ALIPAY' | 'WECHAT' | 'MEITUAN' | 'CMB' | 'BOCOM' +/** 支持的账单类型编码(由后端 /transaction/import/types 下发,此处为已知值提示) */ +export type BillType = string + +/** 账单类型选项(后端下发),extensions 为该来源允许的账单文件扩展名 */ +export interface BillTypeOption { + code: string + extensions: string[] +} + +/** 获取系统支持的账单类型及允许的文件扩展名 */ +export function getBillTypes() { + return request.get('/transaction/import/types') +} /** 账单解析结果行 */ export interface ParsedBillRow { diff --git a/frontend/src/components/BillSourceStatsPanel.vue b/frontend/src/components/BillSourceStatsPanel.vue index a7fbb3b..8467449 100644 --- a/frontend/src/components/BillSourceStatsPanel.vue +++ b/frontend/src/components/BillSourceStatsPanel.vue @@ -3,10 +3,10 @@ import { ref, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { - useMessage, NCard, NSpace, NSwitch, NButton, NIcon, NText, + useMessage, NCard, NSpace, NSwitch, NButton, NIcon, NTag, NAlert, } from 'naive-ui' import { OptionsOutline, SaveOutline } from '@vicons/ionicons5' -import { getBillSourceIncludeInStats, updateBillSourceIncludeInStats } from '../api/app' +import { getBillSourceIncludeInStats, updateBillSourceIncludeInStats } from '../api/setting' import { getChannelLabel } from '../utils/channel' const message = useMessage() @@ -43,6 +43,12 @@ async function handleSave() { } } +/** 点击整行切换该来源的「计入统计」默认值 */ +function toggle(code: string) { + if (loading.value) return + defaults.value[code] = !defaults.value[code] +} + onMounted(loadDefaults) @@ -55,17 +61,24 @@ onMounted(loadDefaults) - {{ t('billSourceStats.hint') }} + + {{ t('billSourceStats.hint') }} +
-
- - - {{ getChannelLabel(String(code)) }} - +
+ {{ getChannelLabel(String(code)) }} +
+ {{ value ? t('billSourceStats.included') : t('billSourceStats.excluded') }} - - + + +
@@ -82,9 +95,32 @@ onMounted(loadDefaults) diff --git a/frontend/src/components/DisplayConfigPanel.vue b/frontend/src/components/DisplayConfigPanel.vue new file mode 100644 index 0000000..f970915 --- /dev/null +++ b/frontend/src/components/DisplayConfigPanel.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index 43a0894..cfb9594 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -137,6 +137,7 @@ export default { viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' }, registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' }, billSourceStatsSection: { zh: '账单来源统计默认值', en: 'Bill Source Stats Defaults', ja: '請求元の集計既定値' }, + displaySection: { zh: '外观设置', en: 'Appearance', ja: '外観設定' }, notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' }, }, @@ -151,6 +152,21 @@ export default { saveFailed: { zh: '保存账单来源统计默认值失败', en: 'Failed to save bill source stats defaults', ja: '請求元の集計既定値の保存に失敗しました' }, }, + // 外观设置 + display: { + hint: { zh: '选择「不计入统计」的记录在收支记录列表中的展示方式', en: 'Choose how records excluded from stats are shown in the transaction list', ja: '集計対象外の記録を収支リストでどのように表示するかを選択します' }, + label: { zh: '不计入统计的记录样式', en: 'Excluded record style', ja: '集計対象外の記録スタイル' }, + strikethrough: { zh: '删除线(整行划线)', en: 'Strikethrough (whole row)', ja: '取り消し線(行全体)' }, + muted: { zh: '灰色标签(类型显示“不计入”,金额置灰)', en: 'Gray label (type shows "Excluded", amount grayed out)', ja: 'グレー表示(種類は「対象外」、金額はグレー)' }, + previewLabel: { zh: '预览', en: 'Preview', ja: 'プレビュー' }, + previewCategory: { zh: '餐饮', en: 'Dining', ja: '食事' }, + previewRemark: { zh: '示例备注', en: 'Sample remark', ja: 'サンプル備考' }, + save: { zh: '保存', en: 'Save', ja: '保存' }, + saveSuccess: { zh: '外观设置已保存', en: 'Appearance settings saved', ja: '外観設定を保存しました' }, + saveFailed: { zh: '保存外观设置失败', en: 'Failed to save appearance settings', ja: '外観設定の保存に失敗しました' }, + loadFailed: { zh: '加载外观设置失败', en: 'Failed to load appearance settings', ja: '外観設定の読み込みに失敗しました' }, + }, + // 注册设置 registration: { statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' }, @@ -331,6 +347,7 @@ export default { maxExpense: { zh: '最大单笔支出', en: 'Largest Expense', ja: '最大の支出' }, avgExpense: { zh: '平均每笔支出', en: 'Avg. Expense', ja: '平均支出' }, trend: { zh: '收支趋势', en: 'Income & Expense Trend', ja: '収支の推移' }, + trendClickHint: { zh: '点击柱子查看对应明细', en: 'Click a bar to view matching records', ja: '棒をクリックすると該当明細を表示' }, expenseCategoryPie: { zh: '支出分类占比', en: 'Expense by Category', ja: '支出カテゴリの割合' }, incomeCategoryPie: { zh: '收入分类占比', en: 'Income by Category', ja: '収入カテゴリの割合' }, expenseChannelPie: { zh: '支出交易渠道占比', en: 'Expense by Channel', ja: '支出の取引チャネル割合' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 5db71c1..62f5076 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -126,6 +126,19 @@ router.beforeEach(async (to, _from, next) => { return } + // 已登录用户访问登录/注册页时,自动跳转到回跳地址或首页 + const userStore = useUserStore() + if ((to.path === '/login' || to.path === '/register') && userStore.token) { + // 仅允许站内路径回跳,避免开放重定向 + const redirect = Array.isArray(to.query.redirect) ? to.query.redirect[0] : to.query.redirect + const target = + typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') + ? redirect + : '/dashboard' + next(target) + return + } + // 注册功能关闭且已有用户时,不允许访问注册页 if (to.path === '/register') { const registrationStore = useRegistrationStore() @@ -138,7 +151,6 @@ router.beforeEach(async (to, _from, next) => { } } - const userStore = useUserStore() // 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页并记录来源,登录后可回跳 if (to.path !== '/login' && to.path !== '/register' && !userStore.token) { next({ path: '/login', query: { redirect: to.fullPath } }) diff --git a/frontend/src/views/BillImportPanel.vue b/frontend/src/views/BillImportPanel.vue index 1ff5e3e..f4af85f 100644 --- a/frontend/src/views/BillImportPanel.vue +++ b/frontend/src/views/BillImportPanel.vue @@ -8,8 +8,8 @@ import { } from 'naive-ui' import type { UploadFileInfo } from 'naive-ui' import { CloudUploadOutline } from '@vicons/ionicons5' -import { parseBill, confirmImport } from '../api/transaction' -import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction' +import { parseBill, confirmImport, getBillTypes } from '../api/transaction' +import type { BillType, BillTypeOption, ParsedBillRow, BillImportItem } from '../api/transaction' import BillPreviewPanel from '../components/BillPreviewPanel.vue' import BillImportResult from '../components/BillImportResult.vue' @@ -24,24 +24,18 @@ const { t } = useI18n() // 国际化 // 账单类型偏好存储键 const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType' -// 账单类型选项,当前支持支付宝、微信、美团、招商银行与交通银行 -const billTypeOptions = computed(() => [ - { label: t('import.billTypeALIPAY'), value: 'ALIPAY' }, - { label: t('import.billTypeWECHAT'), value: 'WECHAT' }, - { label: t('import.billTypeMEITUAN'), value: 'MEITUAN' }, - { label: t('import.billTypeCMB'), value: 'CMB' }, - { label: t('import.billTypeBOCOM'), value: 'BOCOM' }, -]) +// 后端下发的账单类型(编码 + 允许的扩展名),组件挂载时加载 +const billTypes = ref([]) -// 账单类型对应的名称与导出路径文案 key -const billTypeLabelKeys: Record = { +// 账单类型对应的名称与导出路径文案 key(文案由前端按语言维护,新增来源未补文案时回退编码) +const billTypeLabelKeys: Record = { ALIPAY: 'import.billTypeALIPAY', WECHAT: 'import.billTypeWECHAT', MEITUAN: 'import.billTypeMEITUAN', CMB: 'import.billTypeCMB', BOCOM: 'import.billTypeBOCOM', } -const billTypeExportHintKeys: Record = { +const billTypeExportHintKeys: Record = { ALIPAY: 'import.exportHintALIPAY', WECHAT: 'import.exportHintWECHAT', MEITUAN: 'import.exportHintMEITUAN', @@ -49,38 +43,62 @@ const billTypeExportHintKeys: Record = { BOCOM: 'import.exportHintBOCOM', } -/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */ -function loadBillTypePref(): BillType { - const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY) - return saved === 'ALIPAY' || saved === 'WECHAT' || saved === 'MEITUAN' || saved === 'CMB' || saved === 'BOCOM' - ? saved - : 'ALIPAY' +/** 取账单类型对应的当前语言标签,未维护文案时回退编码 */ +function billTypeLabel(code: string) { + const key = billTypeLabelKeys[code] + return key ? t(key) : code } -// 当前账单类型,初始值取上次选择(个人偏好) -const billType = ref(loadBillTypePref()) +// 账单类型选项,来源于后端 +const billTypeOptions = computed(() => + billTypes.value.map((item) => ({ label: billTypeLabel(item.code), value: item.code })), +) -// 各账单类型允许上传的文件扩展名(支持原始文件或 zip 压缩包) -const allowedExtensions: Record = { - ALIPAY: ['.csv', '.zip'], - WECHAT: ['.xlsx', '.zip'], - MEITUAN: ['.csv', '.zip'], - CMB: ['.csv', '.pdf', '.zip'], - BOCOM: ['.xls', '.zip'], +/** 读取上次选择的账单类型偏好 */ +function readBillTypePref(): string { + return localStorage.getItem(BILL_TYPE_PREFS_KEY) || '' } +// 当前账单类型,初始值取上次选择(个人偏好),加载后按后端列表校正 +const billType = ref(readBillTypePref()) + +// 各账单类型允许上传的文件扩展名(后端扩展名 + zip 压缩包) +const allowedExtensions = computed>(() => { + const map: Record = {} + for (const item of billTypes.value) { + map[item.code] = [...item.extensions, '.zip'] + } + return map +}) + // 当前账单类型允许的扩展名,用于上传组件 accept 属性 -const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(',')) +const acceptTypes = computed(() => (allowedExtensions.value[billType.value] || []).join(',')) // 当前账单类型的文件格式提示 const fileHint = computed(() => { - const label = t(billTypeLabelKeys[billType.value]) - const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator')) + const label = billTypeLabel(billType.value) + const exts = (allowedExtensions.value[billType.value] || []).join(t('import.extSeparator')) return t('import.fileHint', { label, exts }) }) // 当前账单类型的导出路径提示 -const exportHint = computed(() => t(billTypeExportHintKeys[billType.value])) +const exportHint = computed(() => { + const key = billTypeExportHintKeys[billType.value] + return key ? t(key) : '' +}) + +/** 加载后端支持的账单类型,并按结果校正当前选择 */ +async function loadBillTypes() { + try { + const res: any = await getBillTypes() + billTypes.value = res.data || [] + const codes = billTypes.value.map((item) => item.code) + const saved = readBillTypePref() + billType.value = codes.includes(saved) ? saved : (codes[0] || '') + } catch (e) { + // 静默处理,账单类型下拉保持为空 + } +} // 面板阶段:选择文件 -> 预览勾选 -> 导入结果 const stage = ref<'select' | 'preview' | 'result'>('select') @@ -100,7 +118,7 @@ const result = ref<{ total: number; imported: number; skipped: number; failed: n /** 判断文件名是否符合当前账单类型允许的扩展名 */ function isAllowedFile(name: string) { - const exts = allowedExtensions[billType.value] || [] + const exts = allowedExtensions.value[billType.value] || [] const lower = name.toLowerCase() return exts.length === 0 || exts.some((ext) => lower.endsWith(ext)) } @@ -109,8 +127,8 @@ function isAllowedFile(name: string) { function handleFileChange(options: { fileList: UploadFileInfo[] }) { const last = options.fileList[options.fileList.length - 1] if (last?.file && !isAllowedFile(last.name)) { - const label = t(billTypeLabelKeys[billType.value]) - const exts = allowedExtensions[billType.value].join(t('import.extSeparator')) + const label = billTypeLabel(billType.value) + const exts = (allowedExtensions.value[billType.value] || []).join(t('import.extSeparator')) message.error(t('import.errorFileType', { label, exts })) uploadFileList.value = [] file.value = null @@ -207,9 +225,10 @@ watch(billType, (value) => { localStorage.setItem(BILL_TYPE_PREFS_KEY, value) }) -// 挂载时重置(页面进入或弹窗打开时均会触发) +// 挂载时重置并加载账单类型(页面进入或弹窗打开时均会触发) onMounted(() => { reset() + loadBillTypes() }) diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index f185d3a..c09093f 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,7 +1,8 @@