feat: 前端新增中英文多语言支持

- 引入 vue-i18n,新增 locales/messages.ts 以中英对照方式维护词条
- 新增 LocaleSwitch 组件,在登录/注册页与主布局顶部栏提供切换入口
- 语言持久化到 localStorage 并同步 <html lang>,Naive UI 组件语言联动
- 各视图、表格列、弹窗与提示文案全部接入国际化
- 修复 confirmImport 缺失的 tagIds 类型定义
This commit is contained in:
2026-09-17 17:03:49 +08:00
parent 65c0d8c550
commit 179e9b78ca
15 changed files with 806 additions and 351 deletions
+1
View File
@@ -16,6 +16,7 @@
"pinia": "^4.0.3",
"vue": "^3.5.41",
"vue-echarts": "^8.1.0",
"vue-i18n": "^11.4.12",
"vue-router": "^4.6.4"
},
"devDependencies": {
+15 -5
View File
@@ -1,12 +1,22 @@
<script setup lang="ts">
// 根组件:提供 Naive UI 的全局配置(中文化)、消息提示和对话框上下文
import { NMessageProvider, NDialogProvider, NConfigProvider } from 'naive-ui'
import { zhCN, dateZhCN } from 'naive-ui' // 中文语言包和日期格式
// 根组件:提供 Naive UI 的全局配置(跟随应用语言)、消息提示和对话框上下文
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
NMessageProvider, NDialogProvider, NConfigProvider,
zhCN, dateZhCN, enUS, dateEnUS,
} from 'naive-ui'
const { locale } = useI18n()
// Naive UI 组件语言与日期格式随当前语言切换
const naiveLocale = computed(() => (locale.value === 'en-US' ? enUS : zhCN))
const naiveDateLocale = computed(() => (locale.value === 'en-US' ? dateEnUS : dateZhCN))
</script>
<template>
<!-- 全局配置:设置中文语言和日期格式 -->
<NConfigProvider :locale="zhCN" :date-locale="dateZhCN">
<!-- 全局配置:设置组件语言和日期格式 -->
<NConfigProvider :locale="naiveLocale" :date-locale="naiveDateLocale">
<!-- 提供全局消息提示能力 -->
<NMessageProvider>
<!-- 提供全局对话框能力 -->
+1
View File
@@ -114,6 +114,7 @@ export function confirmImport(data: {
billType: BillType
expenseCategoryId?: number | null
incomeCategoryId?: number | null
tagIds?: number[]
items: BillImportItem[]
}) {
return request.post('/transaction/import/confirm', data)
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
// 语言切换下拉组件:供主布局顶部栏与登录/注册页复用
import { computed } from 'vue'
import { NSelect } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { setLocale, type LocaleKey } from '../locales'
const { t, locale } = useI18n()
// 语言选项:名称始终以自身语言展示
const options = computed(() => [
{ label: t('locale.zhCN'), value: 'zh-CN' },
{ label: t('locale.enUS'), value: 'en-US' },
])
/** 切换语言 */
function handleUpdate(value: LocaleKey) {
setLocale(value)
}
</script>
<template>
<NSelect
:value="locale"
:options="options"
size="small"
style="width: 120px;"
@update:value="handleUpdate"
/>
</template>
+77
View File
@@ -0,0 +1,77 @@
// 国际化入口:由对照消息构建各语言包,维护当前语言、持久化与 <html lang> 同步
import { createI18n } from 'vue-i18n'
import messages from './messages'
/** 支持的语言标识 */
export type LocaleKey = 'zh-CN' | 'en-US'
/** 单条消息:同时包含中文与英文 */
interface MessageLeaf {
zh: string
en: string
}
// 语言偏好存储键
const STORAGE_KEY = 'locale'
// 支持的语言列表
const SUPPORTED: LocaleKey[] = ['zh-CN', 'en-US']
/** 判断节点是否为消息叶子(同时具备 zh、en 字符串字段) */
function isMessageLeaf(node: unknown): node is MessageLeaf {
return (
!!node &&
typeof node === 'object' &&
typeof (node as MessageLeaf).zh === 'string' &&
typeof (node as MessageLeaf).en === 'string'
)
}
/** 递归将对照消息树拆分为指定语言的嵌套消息树 */
function buildLocaleMessages(node: unknown, lang: 'zh' | 'en'): any {
if (isMessageLeaf(node)) return node[lang]
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
result[key] = buildLocaleMessages(value, lang)
}
return result
}
/** 解析初始语言:localStorage > 浏览器语言 > 简体中文 */
function resolveInitialLocale(): LocaleKey {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved && SUPPORTED.includes(saved as LocaleKey)) {
return saved as LocaleKey
}
if (navigator.language?.toLowerCase().startsWith('en')) {
return 'en-US'
}
return 'zh-CN'
}
const i18n = createI18n({
legacy: false, // 使用 Composition API 模式
globalInjection: true, // 模板中可直接使用 $t
locale: resolveInitialLocale(),
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': buildLocaleMessages(messages, 'zh'),
'en-US': buildLocaleMessages(messages, 'en'),
},
})
/** 获取当前语言标识 */
export function getLocale(): LocaleKey {
return i18n.global.locale.value as LocaleKey
}
/** 切换语言:更新 i18n、持久化到 localStorage,并同步 <html lang> */
export function setLocale(locale: LocaleKey) {
i18n.global.locale.value = locale
localStorage.setItem(STORAGE_KEY, locale)
document.documentElement.lang = locale
}
// 应用启动时同步 <html lang>
document.documentElement.lang = getLocale()
export default i18n
+302
View File
@@ -0,0 +1,302 @@
// 多语言消息定义:每个 key 同时维护中文(zh)与英文(en),一一对应,便于对照维护
export default {
// 语言名称(用于切换器展示,始终以自身语言呈现)
locale: {
zhCN: { zh: '简体中文', en: '简体中文' },
enUS: { zh: 'English', en: 'English' },
},
// 通用文案与枚举
common: {
confirm: { zh: '确定', en: 'Confirm' },
cancel: { zh: '取消', en: 'Cancel' },
delete: { zh: '删除', en: 'Delete' },
edit: { zh: '编辑', en: 'Edit' },
operation: { zh: '操作', en: 'Actions' },
name: { zh: '名称', en: 'Name' },
type: { zh: '类型', en: 'Type' },
sort: { zh: '排序', en: 'Order' },
all: { zh: '全部', en: 'All' },
min: { zh: '最小', en: 'Min' },
max: { zh: '最大', en: 'Max' },
reset: { zh: '重置', en: 'Reset' },
loadFailed: { zh: '加载失败', en: 'Failed to load' },
operationFailed: { zh: '操作失败', en: 'Operation failed' },
createSuccess: { zh: '创建成功', en: 'Created successfully' },
updateSuccess: { zh: '修改成功', en: 'Updated successfully' },
deleteSuccess: { zh: '删除成功', en: 'Deleted successfully' },
deleteFailed: { zh: '删除失败', en: 'Failed to delete' },
confirmDelete: { zh: '确认删除', en: 'Confirm Deletion' },
types: {
expense: { zh: '支出', en: 'Expense' },
income: { zh: '收入', en: 'Income' },
},
payment: {
wechat: { zh: '微信', en: 'WeChat' },
alipay: { zh: '支付宝', en: 'Alipay' },
cash: { zh: '现金', en: 'Cash' },
bank: { zh: '银行卡', en: 'Bank Card' },
},
},
// 登录页
login: {
title: { zh: '收支管理系统 - 登录', en: 'Finance Manager - Sign In' },
username: { zh: '用户名', en: 'Username' },
password: { zh: '密码', en: 'Password' },
usernamePlaceholder: { zh: '请输入用户名', en: 'Enter your username' },
passwordPlaceholder: { zh: '请输入密码', en: 'Enter your password' },
submit: { zh: '登录', en: 'Sign In' },
toRegister: { zh: '没有账号?去注册', en: "Don't have an account? Sign up" },
fillRequired: { zh: '请填写用户名和密码', en: 'Please enter username and password' },
success: { zh: '登录成功', en: 'Signed in successfully' },
failed: { zh: '登录失败', en: 'Sign in failed' },
},
// 注册页
register: {
title: { zh: '收支管理系统 - 注册', en: 'Finance Manager - Sign Up' },
username: { zh: '用户名', en: 'Username' },
nickname: { zh: '昵称', en: 'Nickname' },
password: { zh: '密码', en: 'Password' },
confirmPassword: { zh: '确认密码', en: 'Confirm Password' },
usernamePlaceholder: { zh: '3-50个字符', en: '3-50 characters' },
nicknamePlaceholder: { zh: '可选', en: 'Optional' },
passwordPlaceholder: { zh: '至少6个字符', en: 'At least 6 characters' },
confirmPasswordPlaceholder: { zh: '再次输入密码', en: 'Enter the password again' },
submit: { zh: '注册', en: 'Sign Up' },
toLogin: { zh: '已有账号?去登录', en: 'Already have an account? Sign in' },
fillRequired: { zh: '请填写用户名和密码', en: 'Please enter username and password' },
usernameMin: { zh: '用户名至少3个字符', en: 'Username must be at least 3 characters' },
passwordMin: { zh: '密码至少6个字符', en: 'Password must be at least 6 characters' },
passwordMismatch: { zh: '两次密码不一致', en: 'The two passwords do not match' },
success: { zh: '注册成功,请登录', en: 'Registered successfully, please sign in' },
failed: { zh: '注册失败', en: 'Registration failed' },
},
// 主布局
layout: {
appTitle: { zh: '收支管理', en: 'Finance Manager' },
dashboard: { zh: '统计面板', en: 'Dashboard' },
transactions: { zh: '收支记录', en: 'Transactions' },
categories: { zh: '分类管理', en: 'Categories' },
tags: { zh: '标签管理', en: 'Tags' },
user: { zh: '用户', en: 'User' },
logout: { zh: '退出', en: 'Sign Out' },
},
// 统计面板
dashboard: {
title: { zh: '统计面板', en: 'Dashboard' },
range: {
month: { zh: '本月', en: 'This Month' },
'3month': { zh: '近3月', en: 'Last 3 Months' },
'12month': { zh: '近12月', en: 'Last 12 Months' },
custom: { zh: '自定义', en: 'Custom' },
},
granularity: {
day: { zh: '按日', en: 'Daily' },
week: { zh: '按周', en: 'Weekly' },
month: { zh: '按月', en: 'Monthly' },
quarter: { zh: '按季', en: 'Quarterly' },
year: { zh: '按年', en: 'Yearly' },
},
filterType: { zh: '类型', en: 'Type' },
filterCategory: { zh: '分类', en: 'Category' },
filterPaymentMethod: { zh: '支付方式', en: 'Payment Method' },
minAmount: { zh: '最小金额', en: 'Min Amount' },
maxAmount: { zh: '最大金额', en: 'Max Amount' },
remark: { zh: '备注搜索', en: 'Search Remark' },
resetFilters: { zh: '重置筛选', en: 'Reset Filters' },
totalIncome: { zh: '总收入', en: 'Total Income' },
totalExpense: { zh: '总支出', en: 'Total Expense' },
balance: { zh: '结余', en: 'Balance' },
transactionCount: { zh: '交易笔数', en: 'Transactions' },
avgDailyIncome: { zh: '日均收入', en: 'Avg. Daily Income' },
avgDailyExpense: { zh: '日均支出', en: 'Avg. Daily Expense' },
maxExpense: { zh: '最大单笔支出', en: 'Largest Expense' },
avgExpense: { zh: '平均每笔支出', en: 'Avg. Expense' },
trend: { zh: '收支趋势', en: 'Income & Expense Trend' },
expenseCategoryPie: { zh: '支出分类占比', en: 'Expense by Category' },
paymentPie: { zh: '支付方式分布', en: 'Payment Method Distribution' },
uncategorized: { zh: '未分类', en: 'Uncategorized' },
},
// 收支记录
transaction: {
title: { zh: '收支记录', en: 'Transactions' },
sortLabel: { zh: '排序:', en: 'Sort:' },
sort: {
timeDesc: { zh: '交易时间(新→旧)', en: 'Time (Newest First)' },
timeAsc: { zh: '交易时间(旧→新)', en: 'Time (Oldest First)' },
amountDesc: { zh: '金额(高→低)', en: 'Amount (High to Low)' },
amountAsc: { zh: '金额(低→高)', en: 'Amount (Low to High)' },
},
batchSet: { zh: '一键设置', en: 'Batch Set' },
importBill: { zh: '导入账单', en: 'Import Bill' },
exportExcel: { zh: '导出 Excel', en: 'Export Excel' },
addRecord: { zh: '新增记录', en: 'Add Record' },
moreFilter: { zh: '更多筛选', en: 'More Filters' },
clear: { zh: '清空', en: 'Clear' },
amountRange: { zh: '金额区间', en: 'Amount Range' },
search: { zh: '查询', en: 'Search' },
reset: { zh: '重置', en: 'Reset' },
summary: { zh: '总计 {total} 条 · 第 {page} / {pages} 页 · 每页 {size} 条', en: 'Total {total} · Page {page} / {pages} · {size} per page' },
pageExpense: { zh: '本页支出', en: 'Page Expense' },
pageIncome: { zh: '本页收入', en: 'Page Income' },
pageBalance: { zh: '本页结余', en: 'Page Balance' },
column: {
transactionTime: { zh: '交易时间', en: 'Time' },
type: { zh: '类型', en: 'Type' },
category: { zh: '分类', en: 'Category' },
amount: { zh: '金额', en: 'Amount' },
paymentMethod: { zh: '支付方式', en: 'Payment' },
remark: { zh: '备注', en: 'Remark' },
tags: { zh: '标签', en: 'Tags' },
includeInStats: { zh: '计入统计', en: 'In Stats' },
actions: { zh: '操作', en: 'Actions' },
},
notIncluded: { zh: '不计入', en: 'Excluded' },
uncategorized: { zh: '未分类', en: 'Uncategorized' },
remarkHidden: { zh: '备注已隐藏', en: 'Remark hidden' },
remarkView: { zh: '查看', en: 'View' },
remarkHide: { zh: '隐藏', en: 'Hide' },
remarkCollapse: { zh: '收起', en: 'Collapse' },
remarkUnhide: { zh: '取消隐藏', en: 'Unhide' },
rawData: { zh: '原始数据', en: 'Raw Data' },
rawDataTitle: { zh: '原始账单数据', en: 'Raw Bill Data' },
createdTime: { zh: '创建时间:{time}', en: 'Created: {time}' },
editRecord: { zh: '编辑记录', en: 'Edit Record' },
addRecordTitle: { zh: '新增记录', en: 'Add Record' },
batchTitle: { zh: '一键设置分类与统计', en: 'Batch Set Category & Stats' },
batchScope: { zh: '作用范围', en: 'Scope' },
batchScopeHint: { zh: '将对当前表格展示的 {count} 条记录设置分类与计入统计', en: 'Apply category and stats settings to the {count} records shown in the table' },
expenseCategory: { zh: '支出分类', en: 'Expense Category' },
incomeCategory: { zh: '收入分类', en: 'Income Category' },
notSetExpense: { zh: '不设置支出分类', en: 'No expense category' },
notSetIncome: { zh: '不设置收入分类', en: 'No income category' },
addTag: { zh: '添加标签', en: 'Add Tags' },
removeTag: { zh: '移除标签', en: 'Remove Tags' },
notAddTag: { zh: '不添加标签', en: 'No tags to add' },
notRemoveTag: { zh: '不移除标签', en: 'No tags to remove' },
batchStats: {
keep: { zh: '不修改', en: 'No Change' },
include: { zh: '计入统计', en: 'Include in Stats' },
exclude: { zh: '不计入统计', en: 'Exclude from Stats' },
},
includeInStats: { zh: '计入统计', en: 'In Stats' },
includeInStatsHint: { zh: '关闭后不纳入月度统计(如借款、转账等)', en: 'When off, excluded from monthly stats (e.g. loans, transfers)' },
deleteConfirm: { zh: '确定要删除这条记录吗?', en: 'Are you sure you want to delete this record?' },
selectCategory: { zh: '请选择分类', en: 'Please select a category' },
validAmount: { zh: '请输入有效金额', en: 'Please enter a valid amount' },
selectCategoryPlaceholder: { zh: '请选择分类', en: 'Select a category' },
amountPlaceholder: { zh: '请输入金额', en: 'Enter amount' },
paymentPlaceholder: { zh: '选择支付方式', en: 'Select payment method' },
remarkPlaceholder: { zh: '可选备注说明', en: 'Optional remark' },
tagPlaceholder: { zh: '选择标签', en: 'Select tags' },
noRawData: { zh: '该记录没有原始账单数据', en: 'This record has no raw bill data' },
remarkHiddenSuccess: { zh: '已隐藏备注', en: 'Remark hidden' },
remarkShownSuccess: { zh: '已取消隐藏', en: 'Remark unhidden' },
statsIncluded: { zh: '已计入统计', en: 'Included in stats' },
statsExcluded: { zh: '已排除统计', en: 'Excluded from stats' },
noRecordsToSet: { zh: '当前表格没有可设置的记录', en: 'No records available in the current table' },
batchNothingSelected: { zh: '请至少选择分类、计入统计或标签', en: 'Please select a category, stats option or tag' },
batchUpdated: { zh: '已更新 {count} 条记录 | 已更新 {count} 条记录', en: 'Updated {count} record | Updated {count} records' },
exportSuccess: { zh: '导出成功', en: 'Exported successfully' },
exportFailed: { zh: '导出失败', en: 'Export failed' },
exportFileName: { zh: '交易记录', en: 'transactions' },
includeFilter: {
yes: { zh: '计入', en: 'Included' },
no: { zh: '不计入', en: 'Excluded' },
},
},
// 分类管理
category: {
title: { zh: '分类管理', en: 'Categories' },
add: { zh: '新增分类', en: 'Add Category' },
expenseSection: { zh: '支出分类', en: 'Expense Categories' },
incomeSection: { zh: '收入分类', en: 'Income Categories' },
editTitle: { zh: '编辑分类', en: 'Edit Category' },
addTitle: { zh: '新增分类', en: 'Add Category' },
name: { zh: '分类名称', en: 'Category Name' },
namePlaceholder: { zh: '请输入分类名称', en: 'Enter category name' },
type: { zh: '类型', en: 'Type' },
icon: { zh: '图标', en: 'Icon' },
iconPlaceholder: { zh: '可选,如 emoji', en: 'Optional, e.g. emoji' },
sort: { zh: '排序', en: 'Order' },
nameRequired: { zh: '请输入分类名称', en: 'Please enter a category name' },
deleteConfirm: { zh: '确定要删除这个分类吗?', en: 'Are you sure you want to delete this category?' },
},
// 标签管理
tag: {
title: { zh: '标签管理', en: 'Tags' },
add: { zh: '新增标签', en: 'Add Tag' },
editTitle: { zh: '编辑标签', en: 'Edit Tag' },
addTitle: { zh: '新增标签', en: 'Add Tag' },
name: { zh: '标签名称', en: 'Tag Name' },
namePlaceholder: { zh: '请输入标签名称', en: 'Enter tag name' },
label: { zh: '标签', en: 'Tag' },
color: { zh: '颜色', en: 'Color' },
sort: { zh: '排序', en: 'Order' },
nameRequired: { zh: '请输入标签名称', en: 'Please enter a tag name' },
deleteConfirm: { zh: '删除后将从所有交易记录中移除该标签,确定删除吗?', en: 'This tag will be removed from all transactions. Delete it?' },
},
// 账单导入
import: {
title: { zh: '导入账单', en: 'Import Bill' },
billType: { zh: '账单类型:', en: 'Bill type:' },
billTypeALIPAY: { zh: '支付宝', en: 'Alipay' },
billTypeWECHAT: { zh: '微信', en: 'WeChat' },
billFile: { zh: '账单文件:', en: 'Bill file:' },
dropText: { zh: '点击或拖拽文件到此处', en: 'Click or drag a file here' },
fileHint: { zh: '仅支持{label}导出的账单文件({exts})', en: 'Only {label} bill files are supported ({exts})' },
extSeparator: { zh: '、', en: ', ' },
exportHintALIPAY: { zh: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。', en: 'Export path: Alipay App → Me → Bills → Issue Transaction Record / Export Bill, choose CSV format.' },
exportHintWECHAT: { zh: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。', en: 'Export path: WeChat App → Me → Services → Wallet → Bills → FAQ → Download Bills → For personal reconciliation, choose xlsx format.' },
parse: { zh: '解析', en: 'Parse' },
expenseDefaultCategory: { zh: '支出默认分类(可不选):', en: 'Default expense category (optional):' },
incomeDefaultCategory: { zh: '收入默认分类(可不选):', en: 'Default income category (optional):' },
defaultTags: { zh: '默认标签(可不选):', en: 'Default tags (optional):' },
notSetCategory: { zh: '未选则不计分类', en: 'No category if unselected' },
notSetTag: { zh: '未选则不打标签', en: 'No tags if unselected' },
selectAll: { zh: '全选', en: 'Select All' },
previewSummary: { zh: '共 {total} 条,筛选后 {filtered} 条,已勾选 {checked} 条', en: 'Total {total}, {filtered} after filtering, {checked} selected' },
searchPlaceholder: { zh: '搜索交易对方/商品/单号', en: 'Search counterparty / product / order no.' },
typePlaceholder: { zh: '类型', en: 'Type' },
statusPlaceholder: { zh: '状态', en: 'Status' },
typeFilter: {
income: { zh: '收入', en: 'Income' },
expense: { zh: '支出', en: 'Expense' },
neutral: { zh: '不计入', en: 'Excluded' },
},
statusFilter: {
importable: { zh: '可导入', en: 'Importable' },
duplicate: { zh: '已存在', en: 'Duplicate' },
error: { zh: '不可导入', en: 'Not Importable' },
},
notIncluded: { zh: '不计入', en: 'Excluded' },
notImportable: { zh: '不可导入', en: 'Not Importable' },
prevStep: { zh: '上一步', en: 'Previous' },
confirmImport: { zh: '确认导入({count})', en: 'Confirm Import ({count})' },
resultSummary: { zh: '导入完成:成功 {imported} 条,跳过 {skipped} 条,失败 {failed} 条', en: 'Import complete: {imported} succeeded, {skipped} skipped, {failed} failed' },
done: { zh: '完成', en: 'Done' },
column: {
transactionTime: { zh: '交易时间', en: 'Time' },
type: { zh: '类型', en: 'Type' },
amount: { zh: '金额', en: 'Amount' },
counterparty: { zh: '交易对方', en: 'Counterparty' },
product: { zh: '商品名称', en: 'Product' },
status: { zh: '状态', en: 'Status' },
},
errorFileType: { zh: '账单类型「{label}」仅支持 {exts} 格式的文件', en: 'Bill type "{label}" only supports files in {exts} format' },
selectFileFirst: { zh: '请先选择账单文件', en: 'Please select a bill file first' },
parseFailed: { zh: '解析失败', en: 'Parse failed' },
selectAtLeastOne: { zh: '请至少勾选一条记录', en: 'Please select at least one record' },
importFailed: { zh: '导入失败', en: 'Import failed' },
loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories' },
loadTagsFailed: { zh: '加载标签失败', en: 'Failed to load tags' },
},
}
+2
View File
@@ -3,6 +3,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import naive from 'naive-ui'
import router from './router'
import i18n from './locales'
import App from './App.vue'
// 创建 Vue 应用实例
@@ -10,4 +11,5 @@ const app = createApp(App)
app.use(createPinia()) // 注册 Pinia 状态管理
app.use(router) // 注册路由
app.use(naive) // 全局注册 Naive UI 组件库
app.use(i18n) // 注册国际化
app.mount('#app') // 挂载到 DOM 元素 #app
+68 -68
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 账单导入弹窗:先解析、后勾选导入(默认全选),当前支持支付宝账单
// 账单导入弹窗:先解析、后勾选导入(默认全选),当前支持支付宝与微信账单
import { ref, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NModal, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber,
@@ -19,15 +20,16 @@ const emit = defineEmits<{
}>()
const message = useMessage()
const { t } = useI18n() // 国际化
// 账单类型偏好存储键
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
// 账单类型选项,当前支持支付宝与微信
const billTypeOptions = [
{ label: '支付宝', value: 'ALIPAY' },
{ label: '微信', value: 'WECHAT' },
]
const billTypeOptions = computed(() => [
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' },
])
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
function loadBillTypePref(): BillType {
@@ -44,23 +46,20 @@ const allowedExtensions: Record<BillType, string[]> = {
WECHAT: ['.xlsx'],
}
// 各账单类型的账单导出路径提示
const exportHints: Record<BillType, string> = {
ALIPAY: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。',
WECHAT: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。',
}
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
// 当前账单类型的文件格式提示
const fileHint = computed(() => {
const label = billTypeOptions.find((o) => o.value === billType.value)?.label || ''
return `仅支持${label}导出的账单文件(${(allowedExtensions[billType.value] || []).join('、')})`
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator'))
return t('import.fileHint', { label, exts })
})
// 当前账单类型的导出路径提示
const exportHint = computed(() => exportHints[billType.value])
const exportHint = computed(() =>
billType.value === 'WECHAT' ? t('import.exportHintWECHAT') : t('import.exportHintALIPAY'),
)
// 弹窗阶段:选择文件 -> 预览勾选 -> 导入结果
const stage = ref<'select' | 'preview' | 'result'>('select')
@@ -82,18 +81,18 @@ const filterMinAmount = ref<number | null>(null)
const filterMaxAmount = ref<number | null>(null)
// 类型筛选选项
const typeFilterOptions = [
{ label: '收入', value: 'INCOME' },
{ label: '支出', value: 'EXPENSE' },
{ label: '不计入', value: 'NEUTRAL' },
]
const typeFilterOptions = computed(() => [
{ label: t('import.typeFilter.income'), value: 'INCOME' },
{ label: t('import.typeFilter.expense'), value: 'EXPENSE' },
{ label: t('import.typeFilter.neutral'), value: 'NEUTRAL' },
])
// 状态筛选选项
const statusFilterOptions = [
{ label: '可导入', value: 'importable' },
{ label: '已存在', value: 'duplicate' },
{ label: '不可导入', value: 'error' },
]
const statusFilterOptions = computed(() => [
{ label: t('import.statusFilter.importable'), value: 'importable' },
{ label: t('import.statusFilter.duplicate'), value: 'duplicate' },
{ label: t('import.statusFilter.error'), value: 'error' },
])
/** 重置预览筛选条件 */
function resetFilters() {
@@ -152,7 +151,7 @@ const someChecked = computed(() => {
return checkedVisible.length > 0 && !allChecked.value
})
/** 表格列定义 */
/** 表格列定义(随语言切换响应式更新) */
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
{
title: () =>
@@ -172,31 +171,31 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
}),
},
{
title: '交易时间',
title: t('import.column.transactionTime'),
key: 'transactionTime',
width: 150,
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
},
{
title: '类型',
title: t('import.column.type'),
key: 'type',
width: 90,
render: (row) => {
if (!row.importable) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => '不可导入' })
return h(NTag, { type: 'error', size: 'small' }, { default: () => t('import.notImportable') })
}
if (!row.includeInStats) {
return h(NTag, { type: 'default', size: 'small' }, { default: () => '不计入' })
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('import.notIncluded') })
}
return h(
NTag,
{ type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' },
{ default: () => (row.type === 'INCOME' ? '收入' : '支出') },
{ default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) },
)
},
},
{
title: '金额',
title: t('import.column.amount'),
key: 'amount',
width: 110,
render: (row) => {
@@ -204,10 +203,10 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
return h('span', { style: { color, fontWeight: 'bold' } }, `${row.type === 'INCOME' ? '+' : '-'}${Number(row.amount).toFixed(2)}`)
},
},
{ title: '交易对方', key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
{ title: '商品名称', key: 'product', ellipsis: { tooltip: true } },
{ title: t('import.column.counterparty'), key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
{ title: t('import.column.product'), key: 'product', ellipsis: { tooltip: true } },
{
title: '状态',
title: t('import.column.status'),
key: 'tradeStatus',
width: 150,
render: (row) => {
@@ -215,7 +214,7 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
}
if (row.duplicate) {
return h(NTag, { type: 'warning', size: 'small' }, { default: () => '已存在' })
return h(NTag, { type: 'warning', size: 'small' }, { default: () => t('import.statusFilter.duplicate') })
}
return h('span', null, row.tradeStatus || '-')
},
@@ -229,7 +228,7 @@ async function loadCategories() {
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id }))
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id }))
} catch (e: any) {
message.error(e.message || '加载分类失败')
message.error(e.message || t('import.loadCategoriesFailed'))
}
}
@@ -237,9 +236,9 @@ async function loadCategories() {
async function loadTags() {
try {
const res: any = await getTagList()
allTags.value = (res.data || []).map((t: any) => ({ label: t.name, value: t.id }))
allTags.value = (res.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
} catch (e: any) {
message.error(e.message || '加载标签失败')
message.error(e.message || t('import.loadTagsFailed'))
}
}
@@ -254,8 +253,9 @@ 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 = billTypeOptions.find((o) => o.value === billType.value)?.label || billType.value
message.error(`账单类型「${label}」仅支持 ${allowedExtensions[billType.value].join('、')} 格式的文件`)
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
message.error(t('import.errorFileType', { label, exts }))
uploadFileList.value = []
file.value = null
return
@@ -267,7 +267,7 @@ function handleFileChange(options: { fileList: UploadFileInfo[] }) {
/** 解析账单 */
async function handleParse() {
if (!file.value) {
message.warning('请先选择账单文件')
message.warning(t('import.selectFileFirst'))
return
}
parsing.value = true
@@ -279,7 +279,7 @@ async function handleParse() {
resetFilters()
stage.value = 'preview'
} catch (e: any) {
message.error(e.message || '解析失败')
message.error(e.message || t('import.parseFailed'))
} finally {
parsing.value = false
}
@@ -304,7 +304,7 @@ function toggleAll(checked: boolean) {
/** 确认导入 */
async function handleConfirm() {
if (checkedRows.value.length === 0) {
message.warning('请至少勾选一条记录')
message.warning(t('import.selectAtLeastOne'))
return
}
const items: BillImportItem[] = checkedRows.value.map((r) => ({
@@ -330,7 +330,7 @@ async function handleConfirm() {
stage.value = 'result'
emit('imported')
} catch (e: any) {
message.error(e.message || '导入失败')
message.error(e.message || t('import.importFailed'))
} finally {
importing.value = false
}
@@ -379,7 +379,7 @@ watch(
<NModal
:show="props.show"
preset="card"
title="导入账单"
:title="t('import.title')"
style="width: 960px; max-width: 92vw;"
:mask-closable="false"
@update:show="handleClose"
@@ -388,12 +388,12 @@ watch(
<template v-if="stage === 'select'">
<NSpace vertical :size="16">
<NSpace align="center">
<NText>账单类型:</NText>
<NText>{{ t('import.billType') }}</NText>
<NSelect v-model:value="billType" :options="billTypeOptions" style="width: 160px;" />
</NSpace>
<div>
<NText>账单文件:</NText>
<NText>{{ t('import.billFile') }}</NText>
<NUpload
:file-list="uploadFileList"
:default-upload="false"
@@ -407,7 +407,7 @@ watch(
<NIcon size="48" :depth="3">
<CloudUploadOutline />
</NIcon>
<div style="font-size: 15px; margin-top: 8px;">点击或拖拽文件到此处</div>
<div style="font-size: 15px; margin-top: 8px;">{{ t('import.dropText') }}</div>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
{{ fileHint }}
</div>
@@ -421,8 +421,8 @@ watch(
</NAlert>
<NSpace justify="end">
<NButton @click="handleClose">取消</NButton>
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">解析</NButton>
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">{{ t('import.parse') }}</NButton>
</NSpace>
</NSpace>
</template>
@@ -431,25 +431,25 @@ watch(
<template v-else-if="stage === 'preview'">
<NSpace vertical :size="12">
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NText>支出默认分类(可不选):</NText>
<NText>{{ t('import.expenseDefaultCategory') }}</NText>
<NSelect
v-model:value="expenseCategoryId"
:options="expenseCategories"
filterable
clearable
placeholder="未选则不计分类"
:placeholder="t('import.notSetCategory')"
style="width: 180px;"
/>
<NText>收入默认分类(可不选):</NText>
<NText>{{ t('import.incomeDefaultCategory') }}</NText>
<NSelect
v-model:value="incomeCategoryId"
:options="incomeCategories"
filterable
clearable
placeholder="未选则不计分类"
:placeholder="t('import.notSetCategory')"
style="width: 180px;"
/>
<NText>默认标签(可不选):</NText>
<NText>{{ t('import.defaultTags') }}</NText>
<NSelect
v-model:value="importTagIds"
:options="allTags"
@@ -457,7 +457,7 @@ watch(
clearable
filterable
:max-tag-count="2"
placeholder="未选则不打标签"
:placeholder="t('import.notSetTag')"
style="width: 200px;"
/>
<NCheckbox
@@ -466,20 +466,20 @@ watch(
:disabled="selectableRows.length === 0"
@update:checked="toggleAll"
>
全选
{{ t('import.selectAll') }}
</NCheckbox>
<NText depth="3">共 {{ rows.length }} 条,筛选后 {{ filteredRows.length }} 条,已勾选 {{ checkedRows.length }} 条</NText>
<NText depth="3">{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}</NText>
</NSpace>
<!-- 筛选条件 -->
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NInput v-model:value="filterKeyword" clearable placeholder="搜索交易对方/商品/单号" style="width: 220px;" />
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable placeholder="类型" style="width: 120px;" />
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable placeholder="状态" style="width: 130px;" />
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable placeholder="最小金额" style="width: 120px;" />
<NInput v-model:value="filterKeyword" clearable :placeholder="t('import.searchPlaceholder')" style="width: 220px;" />
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable :placeholder="t('import.typePlaceholder')" style="width: 120px;" />
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable :placeholder="t('import.statusPlaceholder')" style="width: 130px;" />
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable :placeholder="t('common.min')" style="width: 120px;" />
<span style="color: #999;">~</span>
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable placeholder="最大金额" style="width: 120px;" />
<NButton size="small" @click="resetFilters">重置</NButton>
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable :placeholder="t('common.max')" style="width: 120px;" />
<NButton size="small" @click="resetFilters">{{ t('common.reset') }}</NButton>
</NSpace>
<NDataTable
@@ -492,9 +492,9 @@ watch(
/>
<NSpace justify="end">
<NButton @click="stage = 'select'">上一步</NButton>
<NButton @click="stage = 'select'">{{ t('import.prevStep') }}</NButton>
<NButton type="primary" :loading="importing" @click="handleConfirm">
确认导入({{ checkedRows.length }})
{{ t('import.confirmImport', { count: checkedRows.length }) }}
</NButton>
</NSpace>
</NSpace>
@@ -504,7 +504,7 @@ watch(
<template v-else>
<NSpace vertical :size="12">
<NAlert type="success" :show-icon="true">
导入完成:成功 {{ result?.imported ?? 0 }} 条,跳过 {{ result?.skipped ?? 0 }} 条,失败 {{ result?.failed ?? 0 }} 条
{{ t('import.resultSummary', { imported: result?.imported ?? 0, skipped: result?.skipped ?? 0, failed: result?.failed ?? 0 }) }}
</NAlert>
<div v-if="result?.messages?.length" style="max-height: 320px; overflow: auto;">
<NText v-for="(msg, i) in result.messages" :key="i" depth="3" style="display: block; font-size: 12px;">
@@ -512,7 +512,7 @@ watch(
</NText>
</div>
<NSpace justify="end">
<NButton type="primary" @click="handleClose">完成</NButton>
<NButton type="primary" @click="handleClose">{{ t('import.done') }}</NButton>
</NSpace>
</NSpace>
</template>
+36 -34
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
// 分类管理组件:展示收入/支出分类列表,支持新增、编辑、删除分类
import { ref, onMounted, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getCategoryList, createCategory, updateCategory, deleteCategory } from '../api/category'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const { t } = useI18n() // 国际化
/** 分类数据结构 */
interface CategoryItem {
@@ -26,10 +28,10 @@ const editingId = ref<number | null>(null) // 当前编辑的分类 ID(null
const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据
// 类型选项
const typeOptions = [
{ label: '支出', value: 'EXPENSE' },
{ label: '收入', value: 'INCOME' },
]
const typeOptions = computed(() => [
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
// 按类型过滤分类列表
const incomeList = computed(() => categories.value.filter(c => c.type === 'INCOME')) // 收入分类
@@ -38,18 +40,18 @@ const expenseList = computed(() => categories.value.filter(c => c.type === 'EXPE
/** 创建表格列配置 */
function createColumns(): DataTableColumns<CategoryItem> {
return [
{ title: '名称', key: 'name' },
{ title: '图标', key: 'icon' },
{ title: '排序', key: 'sortOrder', width: 80 },
{ title: t('common.name'), key: 'name' },
{ title: t('category.icon'), key: 'icon' },
{ title: t('common.sort'), key: 'sortOrder', width: 80 },
{
title: '操作', key: 'actions', width: 180,
title: t('common.operation'), key: 'actions', width: 180,
render(row) {
// 编辑和删除按钮
return [
h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
],
}),
]
@@ -58,7 +60,7 @@ function createColumns(): DataTableColumns<CategoryItem> {
]
}
const columns = createColumns() // 表格列配置
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
/** 加载分类列表数据 */
async function fetchData() {
@@ -67,7 +69,7 @@ async function fetchData() {
const res: any = await getCategoryList()
categories.value = res.data
} catch (e: any) {
message.error(e.message || '加载失败')
message.error(e.message || t('common.loadFailed'))
} finally {
loading.value = false
}
@@ -90,40 +92,40 @@ function handleEdit(row: CategoryItem) {
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
async function handleSubmit() {
if (!form.value.name) {
message.warning('请输入分类名称')
message.warning(t('category.nameRequired'))
return
}
try {
if (editingId.value) {
// 更新已有分类
await updateCategory(editingId.value, form.value)
message.success('修改成功')
message.success(t('common.updateSuccess'))
} else {
// 创建新分类
await createCategory(form.value)
message.success('创建成功')
message.success(t('common.createSuccess'))
}
showModal.value = false
await fetchData() // 刷新列表
} catch (e: any) {
message.error(e.message || '操作失败')
message.error(e.message || t('common.operationFailed'))
}
}
/** 删除分类:弹出确认对话框,确认后执行删除并刷新列表 */
function handleDelete(id: number) {
dialog.warning({
title: '确认删除',
content: '确定要删除这个分类吗?',
positiveText: '删除',
negativeText: '取消',
title: t('common.confirmDelete'),
content: t('category.deleteConfirm'),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await deleteCategory(id)
message.success('删除成功')
message.success(t('common.deleteSuccess'))
await fetchData()
} catch (e: any) {
message.error(e.message || '删除失败')
message.error(e.message || t('common.deleteFailed'))
}
},
})
@@ -137,34 +139,34 @@ onMounted(fetchData)
<div>
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px;">
<h2 style="margin: 0;">分类管理</h2>
<NButton type="primary" @click="handleAdd">新增分类</NButton>
<h2 style="margin: 0;">{{ t('category.title') }}</h2>
<NButton type="primary" @click="handleAdd">{{ t('category.add') }}</NButton>
</NSpace>
<!-- 支出分类列表 -->
<h3>支出分类</h3>
<h3>{{ t('category.expenseSection') }}</h3>
<NDataTable :columns="columns" :data="expenseList" :loading="loading" :bordered="false" />
<!-- 收入分类列表 -->
<h3 style="margin-top: 24px;">收入分类</h3>
<h3 style="margin-top: 24px;">{{ t('category.incomeSection') }}</h3>
<NDataTable :columns="columns" :data="incomeList" :loading="loading" :bordered="false" />
<!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? '编辑分类' : '新增分类'" style="width: 450px;">
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('category.editTitle') : t('category.addTitle')" style="width: 450px;">
<NForm>
<NFormItem label="分类名称">
<NInput v-model:value="form.name" placeholder="请输入分类名称" />
<NFormItem :label="t('category.name')">
<NInput v-model:value="form.name" :placeholder="t('category.namePlaceholder')" />
</NFormItem>
<NFormItem label="类型">
<NFormItem :label="t('category.type')">
<NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem>
<NFormItem label="图标">
<NInput v-model:value="form.icon" placeholder="可选,如 emoji" />
<NFormItem :label="t('category.icon')">
<NInput v-model:value="form.icon" :placeholder="t('category.iconPlaceholder')" />
</NFormItem>
<NFormItem label="排序">
<NFormItem :label="t('category.sort')">
<NInputNumber v-model:value="form.sortOrder" :min="0" />
</NFormItem>
<NButton type="primary" block @click="handleSubmit">确定</NButton>
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
</div>
+55 -46
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与支付方式分布
import { ref, computed, onMounted, watch, nextTick, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
@@ -13,21 +14,23 @@ import { getCategoryList } from '../api/category'
// 注册 ECharts 所需组件
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
const { t, locale } = useI18n() // 国际化
// 时间范围快捷选项
const rangeOptions = [
{ label: '本月', value: 'month' },
{ label: '近3月', value: '3month' },
{ label: '近12月', value: '12month' },
{ label: '自定义', value: 'custom' },
]
const rangeOptions = computed(() => [
{ label: t('dashboard.range.month'), value: 'month' },
{ label: t('dashboard.range.3month'), value: '3month' },
{ label: t('dashboard.range.12month'), value: '12month' },
{ label: t('dashboard.range.custom'), value: 'custom' },
])
// 趋势图时间粒度选项
const granularityOptions = [
{ label: '按日', value: 'day' },
{ label: '按周', value: 'week' },
{ label: '按月', value: 'month' },
{ label: '按季', value: 'quarter' },
{ label: '按年', value: 'year' },
]
const granularityOptions = computed(() => [
{ label: t('dashboard.granularity.day'), value: 'day' },
{ label: t('dashboard.granularity.week'), value: 'week' },
{ label: t('dashboard.granularity.month'), value: 'month' },
{ label: t('dashboard.granularity.quarter'), value: 'quarter' },
{ label: t('dashboard.granularity.year'), value: 'year' },
])
const rangeType = ref<'month' | '3month' | '12month' | 'custom'>('month') // 时间范围类型
const customRange = ref<[number, number] | null>(null) // 自定义起止时间戳
@@ -42,16 +45,16 @@ const filterMaxAmount = ref<number | null>(null) // 最大金额
const filterRemark = ref('') // 备注关键字
// 类型与支付方式选项
const typeOptions = [
{ label: '支出', value: 'EXPENSE' },
{ label: '收入', value: 'INCOME' },
]
const paymentOptions = [
{ label: '微信', value: 'WECHAT' },
{ label: '支付宝', value: 'ALIPAY' },
{ label: '现金', value: 'CASH' },
{ label: '银行卡', value: 'BANK' },
]
const typeOptions = computed(() => [
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
const paymentOptions = computed(() => [
{ label: t('common.payment.wechat'), value: 'WECHAT' },
{ label: t('common.payment.alipay'), value: 'ALIPAY' },
{ label: t('common.payment.cash'), value: 'CASH' },
{ label: t('common.payment.bank'), value: 'BANK' },
])
/** 分类选项数据结构 */
interface CategoryOption {
@@ -64,7 +67,7 @@ interface CategoryOption {
const categories = ref<CategoryOption[]>([]) // 分类选项列表
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
const categoryFilterOptions = computed(() => [
{ label: '未分类', value: 0, type: '' },
{ label: t('dashboard.uncategorized'), value: 0, type: '' },
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
])
@@ -180,7 +183,7 @@ function buildKeys(start: Date, end: Date, gran: string): string[] {
/** 更新支出分类饼图 */
function updatePieChart() {
pieOption.value = {
title: { text: '支出分类占比', left: 'center' },
title: { text: t('dashboard.expenseCategoryPie'), left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
@@ -197,7 +200,7 @@ function updatePieChart() {
/** 更新支付方式分布饼图 */
function updatePaymentChart() {
paymentOption.value = {
title: { text: '支付方式分布', left: 'center' },
title: { text: t('dashboard.paymentPie'), left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
@@ -222,13 +225,13 @@ function updateTrendChart() {
trendOption.value = {
tooltip: { trigger: 'axis' },
legend: { data: ['收入', '支出'], bottom: 0 },
legend: { data: [t('common.types.income'), t('common.types.expense')], bottom: 0 },
grid: { left: 60, right: 20, top: 40, bottom: 40 },
xAxis: { type: 'category', data: labels },
yAxis: { type: 'value' },
series: [
{ name: '收入', type: 'bar', data: income, color: '#18a058' },
{ name: '支出', type: 'bar', data: expense, color: '#d03050' },
{ name: t('common.types.income'), type: 'bar', data: income, color: '#18a058' },
{ name: t('common.types.expense'), type: 'bar', data: expense, color: '#d03050' },
],
}
}
@@ -332,6 +335,12 @@ watch(rangeType, (v) => {
watch([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData)
// 任一偏好变化时自动保存
watch([rangeType, customRange, granularity, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
// 语言切换时重绘图表,更新标题与图例文案
watch(locale, () => {
updatePieChart()
updatePaymentChart()
updateTrendChart()
})
onMounted(async () => {
loadPrefs()
@@ -346,7 +355,7 @@ onMounted(async () => {
<div>
<!-- 顶部:标题与时间范围选择 -->
<NSpace justify="space-between" align="center" style="margin-bottom: 20px;" :wrap="true">
<h2 style="margin: 0;">统计面板</h2>
<h2 style="margin: 0;">{{ t('dashboard.title') }}</h2>
<NSpace align="center" :wrap="true">
<NSelect v-model:value="rangeType" :options="rangeOptions" style="width: 110px;" />
<NDatePicker
@@ -369,7 +378,7 @@ onMounted(async () => {
multiple
clearable
:max-tag-count="2"
placeholder="类型"
:placeholder="t('dashboard.filterType')"
style="width: 160px;"
/>
<NSelect
@@ -380,7 +389,7 @@ onMounted(async () => {
filterable
:max-tag-count="2"
:render-label="renderCategoryLabel"
placeholder="分类"
:placeholder="t('dashboard.filterCategory')"
style="width: 200px;"
/>
<NSelect
@@ -389,7 +398,7 @@ onMounted(async () => {
multiple
clearable
:max-tag-count="2"
placeholder="支付方式"
:placeholder="t('dashboard.filterPaymentMethod')"
style="width: 200px;"
/>
<NInputNumber
@@ -397,7 +406,7 @@ onMounted(async () => {
:min="0"
:precision="2"
clearable
placeholder="最小金额"
:placeholder="t('dashboard.minAmount')"
style="width: 130px;"
/>
<span style="color: #999;">~</span>
@@ -406,16 +415,16 @@ onMounted(async () => {
:min="0"
:precision="2"
clearable
placeholder="最大金额"
:placeholder="t('dashboard.maxAmount')"
style="width: 130px;"
/>
<NInput
v-model:value="filterRemark"
clearable
placeholder="备注搜索"
:placeholder="t('dashboard.remark')"
style="width: 180px;"
/>
<NButton @click="handleResetFilters">重置筛选</NButton>
<NButton @click="handleResetFilters">{{ t('dashboard.resetFilters') }}</NButton>
</NSpace>
</NCard>
@@ -423,26 +432,26 @@ onMounted(async () => {
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 16px;">
<NGridItem>
<NCard>
<NStatistic label="总收入" :value="Number(summary.totalIncome).toFixed(2)" style="color: #18a058;">
<NStatistic :label="t('dashboard.totalIncome')" :value="Number(summary.totalIncome).toFixed(2)" style="color: #18a058;">
<template #prefix>+</template>
</NStatistic>
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="总支出" :value="Number(summary.totalExpense).toFixed(2)" style="color: #d03050;">
<NStatistic :label="t('dashboard.totalExpense')" :value="Number(summary.totalExpense).toFixed(2)" style="color: #d03050;">
<template #prefix>-</template>
</NStatistic>
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="结余" :value="Number(summary.balance).toFixed(2)" />
<NStatistic :label="t('dashboard.balance')" :value="Number(summary.balance).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="交易笔数" :value="summary.transactionCount" />
<NStatistic :label="t('dashboard.transactionCount')" :value="summary.transactionCount" />
</NCard>
</NGridItem>
</NGrid>
@@ -451,17 +460,17 @@ onMounted(async () => {
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
<NGridItem>
<NCard>
<NStatistic label="日均收入" :value="Number(summary.avgDailyIncome).toFixed(2)" />
<NStatistic :label="t('dashboard.avgDailyIncome')" :value="Number(summary.avgDailyIncome).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="日均支出" :value="Number(summary.avgDailyExpense).toFixed(2)" />
<NStatistic :label="t('dashboard.avgDailyExpense')" :value="Number(summary.avgDailyExpense).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="最大单笔支出" :value="Number(summary.maxExpenseAmount).toFixed(2)">
<NStatistic :label="t('dashboard.maxExpense')" :value="Number(summary.maxExpenseAmount).toFixed(2)">
<template #suffix>
<span style="font-size: 12px; color: #999;">{{ (summary.maxExpenseRemark || '').slice(0, 12) }}</span>
</template>
@@ -470,7 +479,7 @@ onMounted(async () => {
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="平均每笔支出" :value="Number(summary.avgExpenseAmount).toFixed(2)" />
<NStatistic :label="t('dashboard.avgExpense')" :value="Number(summary.avgExpenseAmount).toFixed(2)" />
</NCard>
</NGridItem>
</NGrid>
@@ -479,7 +488,7 @@ onMounted(async () => {
<NCard style="margin-bottom: 16px;">
<template #header>
<NSpace justify="space-between" align="center">
<span>收支趋势</span>
<span>{{ t('dashboard.trend') }}</span>
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" />
</NSpace>
</template>
+15 -11
View File
@@ -2,14 +2,17 @@
// 主布局组件:包含左侧导航菜单、顶部用户信息栏和内容区域
import { h, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NButton, NSpace, NIcon } from 'naive-ui'
import type { MenuOption } from 'naive-ui'
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, LogOutOutline } from '@vicons/ionicons5'
import { useUserStore } from '../store/user'
import LocaleSwitch from '../components/LocaleSwitch.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const { t } = useI18n()
// 根据当前路由路径计算菜单激活项
const activeKey = computed(() => {
@@ -27,13 +30,13 @@ function renderIcon(icon: any) {
return () => h(NIcon, null, { default: () => h(icon) })
}
// 侧边栏菜单配置项
const menuOptions: MenuOption[] = [
{ label: '统计面板', key: 'dashboard', icon: renderIcon(BarChartOutline) },
{ label: '收支记录', key: 'transactions', icon: renderIcon(ListOutline) },
{ label: '分类管理', key: 'categories', icon: renderIcon(FolderOpenOutline) },
{ label: '标签管理', key: 'tags', icon: renderIcon(PricetagsOutline) },
]
// 侧边栏菜单配置项(随语言切换响应式更新)
const menuOptions = computed<MenuOption[]>(() => [
{ label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) },
{ label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) },
{ label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) },
{ label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) },
])
/** 菜单点击事件:根据 key 导航到对应路由 */
function handleMenuUpdate(key: string) {
@@ -57,7 +60,7 @@ function handleLogout() {
>
<!-- 系统标题 -->
<div style="text-align: center; padding: 16px 0; font-size: 18px; font-weight: bold; color: #333;">
收支管理
{{ t('layout.appTitle') }}
</div>
<!-- 导航菜单 -->
<NMenu
@@ -69,13 +72,14 @@ function handleLogout() {
<NLayout>
<!-- 顶部栏:显示用户名和退出按钮 -->
<NLayoutHeader bordered style="padding: 12px 24px; display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 16px; font-weight: 500;">{{ userStore.nickname || '用户' }}</span>
<NSpace>
<span style="font-size: 16px; font-weight: 500;">{{ userStore.nickname || t('layout.user') }}</span>
<NSpace align="center">
<LocaleSwitch />
<NButton quaternary size="small" @click="handleLogout">
<template #icon>
<NIcon :component="LogOutOutline" />
</template>
退出
{{ t('layout.logout') }}
</NButton>
</NSpace>
</NLayoutHeader>
+16 -10
View File
@@ -2,13 +2,16 @@
// 登录页面组件:提供用户名/密码表单,验证后跳转至首页
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
import { login } from '../api/user'
import { useUserStore } from '../store/user'
import LocaleSwitch from '../components/LocaleSwitch.vue'
const router = useRouter()
const message = useMessage() // 消息提示工具
const userStore = useUserStore() // 用户状态管理
const { t } = useI18n() // 国际化
// 登录表单数据
const form = ref({
@@ -20,7 +23,7 @@ const loading = ref(false) // 登录按钮加载状态
/** 处理登录操作:校验表单 -> 调用登录接口 -> 保存 token -> 跳转首页 */
async function handleLogin() {
if (!form.value.username || !form.value.password) {
message.warning('请填写用户名和密码')
message.warning(t('login.fillRequired'))
return
}
loading.value = true
@@ -28,10 +31,10 @@ async function handleLogin() {
const res: any = await login(form.value)
userStore.setToken(res.data.token) // 保存 token
userStore.setUserInfo(res.data.user.nickname, res.data.user.id) // 保存用户信息
message.success('登录成功')
message.success(t('login.success'))
router.push('/dashboard') // 跳转到统计面板
} catch (e: any) {
message.error(e.message || '登录失败')
message.error(e.message || t('login.failed'))
} finally {
loading.value = false
}
@@ -40,17 +43,20 @@ async function handleLogin() {
<template>
<div class="login-container">
<NCard title="收支管理系统 - 登录" style="width: 400px" :bordered="true">
<NCard :title="t('login.title')" style="width: 400px" :bordered="true">
<template #header-extra>
<LocaleSwitch />
</template>
<NForm>
<NFormItem label="用户名">
<NInput v-model:value="form.username" placeholder="请输入用户名" @keyup.enter="handleLogin" />
<NFormItem :label="t('login.username')">
<NInput v-model:value="form.username" :placeholder="t('login.usernamePlaceholder')" @keyup.enter="handleLogin" />
</NFormItem>
<NFormItem label="密码">
<NInput v-model:value="form.password" type="password" show-password-on="click" placeholder="请输入密码" @keyup.enter="handleLogin" />
<NFormItem :label="t('login.password')">
<NInput v-model:value="form.password" type="password" show-password-on="click" :placeholder="t('login.passwordPlaceholder')" @keyup.enter="handleLogin" />
</NFormItem>
<NSpace vertical>
<NButton type="primary" block :loading="loading" @click="handleLogin">登录</NButton>
<NButton text type="info" @click="router.push('/register')">没有账号?去注册</NButton>
<NButton type="primary" block :loading="loading" @click="handleLogin">{{ t('login.submit') }}</NButton>
<NButton text type="info" @click="router.push('/register')">{{ t('login.toRegister') }}</NButton>
</NSpace>
</NForm>
</NCard>
+23 -17
View File
@@ -2,11 +2,14 @@
// 注册页面组件:提供注册表单(用户名、昵称、密码、确认密码),校验后调用注册接口
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
import { register } from '../api/user'
import LocaleSwitch from '../components/LocaleSwitch.vue'
const router = useRouter()
const message = useMessage() // 消息提示工具
const { t } = useI18n() // 国际化
// 注册表单数据
const form = ref({
@@ -21,20 +24,20 @@ const loading = ref(false) // 注册按钮加载状态
async function handleRegister() {
// 校验必填字段
if (!form.value.username || !form.value.password) {
message.warning('请填写用户名和密码')
message.warning(t('register.fillRequired'))
return
}
if (form.value.username.length < 3) {
message.warning('用户名至少3个字符')
message.warning(t('register.usernameMin'))
return
}
if (form.value.password.length < 6) {
message.warning('密码至少6个字符')
message.warning(t('register.passwordMin'))
return
}
// 校验两次密码是否一致
if (form.value.password !== form.value.confirmPassword) {
message.warning('两次密码不一致')
message.warning(t('register.passwordMismatch'))
return
}
loading.value = true
@@ -44,10 +47,10 @@ async function handleRegister() {
password: form.value.password,
nickname: form.value.nickname || undefined, // 昵称为空时传 undefined
})
message.success('注册成功,请登录')
message.success(t('register.success'))
router.push('/login') // 跳转到登录页
} catch (e: any) {
message.error(e.message || '注册失败')
message.error(e.message || t('register.failed'))
} finally {
loading.value = false
}
@@ -56,23 +59,26 @@ async function handleRegister() {
<template>
<div class="login-container">
<NCard title="收支管理系统 - 注册" style="width: 400px" :bordered="true">
<NCard :title="t('register.title')" style="width: 400px" :bordered="true">
<template #header-extra>
<LocaleSwitch />
</template>
<NForm>
<NFormItem label="用户名">
<NInput v-model:value="form.username" placeholder="3-50个字符" />
<NFormItem :label="t('register.username')">
<NInput v-model:value="form.username" :placeholder="t('register.usernamePlaceholder')" />
</NFormItem>
<NFormItem label="昵称">
<NInput v-model:value="form.nickname" placeholder="可选" />
<NFormItem :label="t('register.nickname')">
<NInput v-model:value="form.nickname" :placeholder="t('register.nicknamePlaceholder')" />
</NFormItem>
<NFormItem label="密码">
<NInput v-model:value="form.password" type="password" show-password-on="click" placeholder="至少6个字符" />
<NFormItem :label="t('register.password')">
<NInput v-model:value="form.password" type="password" show-password-on="click" :placeholder="t('register.passwordPlaceholder')" />
</NFormItem>
<NFormItem label="确认密码">
<NInput v-model:value="form.confirmPassword" type="password" show-password-on="click" placeholder="再次输入密码" />
<NFormItem :label="t('register.confirmPassword')">
<NInput v-model:value="form.confirmPassword" type="password" show-password-on="click" :placeholder="t('register.confirmPasswordPlaceholder')" />
</NFormItem>
<NSpace vertical>
<NButton type="primary" block :loading="loading" @click="handleRegister">注册</NButton>
<NButton text type="info" @click="router.push('/login')">已有账号?去登录</NButton>
<NButton type="primary" block :loading="loading" @click="handleRegister">{{ t('register.submit') }}</NButton>
<NButton text type="info" @click="router.push('/login')">{{ t('register.toLogin') }}</NButton>
</NSpace>
</NForm>
</NCard>
+29 -27
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
// 标签管理组件:展示标签列表,支持新增、编辑、删除标签
import { ref, onMounted, h } from 'vue'
import { ref, onMounted, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NColorPicker, NTag } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getTagList, createTag, updateTag, deleteTag } from '../api/tag'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const { t } = useI18n() // 国际化
/** 标签数据结构 */
interface TagItem {
@@ -28,20 +30,20 @@ const form = ref<{ name: string; color: string; sortOrder: number }>({ name: '',
function createColumns(): DataTableColumns<TagItem> {
return [
{
title: '标签', key: 'name',
title: t('tag.label'), key: 'name',
render(row) {
return h(NTag, { color: { color: row.color || '#2080f0', textColor: '#fff' } }, { default: () => row.name })
},
},
{ title: '颜色', key: 'color', width: 140 },
{ title: '排序', key: 'sortOrder', width: 80 },
{ title: t('tag.color'), key: 'color', width: 140 },
{ title: t('common.sort'), key: 'sortOrder', width: 80 },
{
title: '操作', key: 'actions', width: 180,
title: t('common.operation'), key: 'actions', width: 180,
render(row) {
return h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
],
})
},
@@ -49,7 +51,7 @@ function createColumns(): DataTableColumns<TagItem> {
]
}
const columns = createColumns() // 表格列配置
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
/** 加载标签列表数据 */
async function fetchData() {
@@ -58,7 +60,7 @@ async function fetchData() {
const res: any = await getTagList()
tags.value = res.data
} catch (e: any) {
message.error(e.message || '加载失败')
message.error(e.message || t('common.loadFailed'))
} finally {
loading.value = false
}
@@ -81,38 +83,38 @@ function handleEdit(row: TagItem) {
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
async function handleSubmit() {
if (!form.value.name) {
message.warning('请输入标签名称')
message.warning(t('tag.nameRequired'))
return
}
try {
if (editingId.value) {
await updateTag(editingId.value, form.value)
message.success('修改成功')
message.success(t('common.updateSuccess'))
} else {
await createTag(form.value)
message.success('创建成功')
message.success(t('common.createSuccess'))
}
showModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || '操作失败')
message.error(e.message || t('common.operationFailed'))
}
}
/** 删除标签:弹出确认对话框,确认后执行删除并刷新列表 */
function handleDelete(id: number) {
dialog.warning({
title: '确认删除',
content: '删除后将从所有交易记录中移除该标签,确定删除吗?',
positiveText: '删除',
negativeText: '取消',
title: t('common.confirmDelete'),
content: t('tag.deleteConfirm'),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await deleteTag(id)
message.success('删除成功')
message.success(t('common.deleteSuccess'))
await fetchData()
} catch (e: any) {
message.error(e.message || '删除失败')
message.error(e.message || t('common.deleteFailed'))
}
},
})
@@ -126,26 +128,26 @@ onMounted(fetchData)
<div>
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px;">
<h2 style="margin: 0;">标签管理</h2>
<NButton type="primary" @click="handleAdd">新增标签</NButton>
<h2 style="margin: 0;">{{ t('tag.title') }}</h2>
<NButton type="primary" @click="handleAdd">{{ t('tag.add') }}</NButton>
</NSpace>
<!-- 标签数据表格 -->
<NDataTable :columns="columns" :data="tags" :loading="loading" :bordered="false" />
<!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? '编辑标签' : '新增标签'" style="width: 450px;">
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('tag.editTitle') : t('tag.addTitle')" style="width: 450px;">
<NForm>
<NFormItem label="标签名称">
<NInput v-model:value="form.name" placeholder="请输入标签名称" />
<NFormItem :label="t('tag.name')">
<NInput v-model:value="form.name" :placeholder="t('tag.namePlaceholder')" />
</NFormItem>
<NFormItem label="颜色">
<NFormItem :label="t('tag.color')">
<NColorPicker v-model:value="form.color" />
</NFormItem>
<NFormItem label="排序">
<NFormItem :label="t('tag.sort')">
<NInputNumber v-model:value="form.sortOrder" :min="0" />
</NFormItem>
<NButton type="primary" block @click="handleSubmit">确定</NButton>
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
</div>
+136 -133
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
import { ref, onMounted, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover } from 'naive-ui'
import { EyeOutline, EyeOffOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
@@ -11,6 +12,7 @@ import BillImportModal from './BillImportModal.vue'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const { t, te } = useI18n() // 国际化
/** 交易记录数据结构 */
interface TransactionItem {
@@ -51,12 +53,12 @@ const total = ref(0) // 总记录数
const page = ref(1) // 当前页码
const pageSize = ref(20) // 每页条数
const sortValue = ref('transactionTime-desc') // 排序方式:排序字段-方向
const sortOptions = [
{ label: '交易时间(新→旧)', value: 'transactionTime-desc' },
{ label: '交易时间(旧→新)', value: 'transactionTime-asc' },
{ label: '金额(高→低)', value: 'amount-desc' },
{ label: '金额(低→高)', value: 'amount-asc' },
]
const sortOptions = computed(() => [
{ label: t('transaction.sort.timeDesc'), value: 'transactionTime-desc' },
{ label: t('transaction.sort.timeAsc'), value: 'transactionTime-asc' },
{ label: t('transaction.sort.amountDesc'), value: 'amount-desc' },
{ label: t('transaction.sort.amountAsc'), value: 'amount-asc' },
])
const sortField = computed(() => sortValue.value.split('-')[0]) // 服务端排序字段
const sortOrder = computed(() => sortValue.value.split('-')[1]) // 服务端排序方向:asc/desc
const pageSizes = [10, 20, 50, 100, 200, 500] // 可选的每页条数
@@ -86,11 +88,11 @@ const batchRemoveTagIds = ref<number[]>([]) // 批量移除的标签
const batching = ref(false) // 批量设置提交状态
// 批量设置计入统计选项
const batchStatsOptions = [
{ label: '不修改', value: 'null' },
{ label: '计入统计', value: 'true' },
{ label: '不计入统计', value: 'false' },
]
const batchStatsOptions = computed(() => [
{ label: t('transaction.batchStats.keep'), value: 'null' },
{ label: t('transaction.batchStats.include'), value: 'true' },
{ label: t('transaction.batchStats.exclude'), value: 'false' },
])
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
const categories = ref<CategoryOption[]>([]) // 分类选项列表
const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量)
@@ -109,18 +111,18 @@ const form = ref({
})
// 类型选项
const typeOptions = [
{ label: '支出', value: 'EXPENSE' },
{ label: '收入', value: 'INCOME' },
]
const typeOptions = computed(() => [
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
// 支付方式选项
const paymentOptions = [
{ label: '微信', value: 'WECHAT' },
{ label: '支付宝', value: 'ALIPAY' },
{ label: '现金', value: 'CASH' },
{ label: '银行卡', value: 'BANK' },
]
const paymentOptions = computed(() => [
{ label: t('common.payment.wechat'), value: 'WECHAT' },
{ label: t('common.payment.alipay'), value: 'ALIPAY' },
{ label: t('common.payment.cash'), value: 'CASH' },
{ label: t('common.payment.bank'), value: 'BANK' },
])
// 筛选条件状态
const filterType = ref<string[]>([]) // 类型筛选(多选)
@@ -155,7 +157,7 @@ function clearMoreFilters() {
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
const categoryFilterOptions = computed(() => [
{ label: '未分类', value: 0, type: '' },
{ label: t('transaction.uncategorized'), value: 0, type: '' },
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
])
@@ -172,10 +174,10 @@ function buildCategoryFilter() {
}
// 计入统计筛选下拉选项
const includeInStatsOptions = [
{ label: '计入', value: 'true' },
{ label: '不计入', value: 'false' },
]
const includeInStatsOptions = computed(() => [
{ label: t('transaction.includeFilter.yes'), value: 'true' },
{ label: t('transaction.includeFilter.no'), value: 'false' },
])
/** 筛选条件偏好存储键 */
const FILTER_PREFS_KEY = 'finance.transaction.filterPrefs'
@@ -195,7 +197,7 @@ function loadFilterPrefs() {
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
if (typeof p.pageSize === 'number' && pageSizes.includes(p.pageSize)) pageSize.value = p.pageSize
if (typeof p.sortValue === 'string' && sortOptions.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
if (typeof p.sortValue === 'string' && sortOptions.value.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
if (Array.isArray(p.filterTag)) filterTag.value = p.filterTag
} catch (e) {
// 忽略损坏的偏好数据
@@ -239,10 +241,11 @@ function formatTimestamp(ts: number, isEnd = false): string {
return `${date}T${time}`
}
/** 将支付方式编码转换为中文标签 */
/** 将支付方式编码转换为当前语言的标签 */
function getPaymentLabel(val: string) {
const map: Record<string, string> = { WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', BANK: '银行卡' }
return map[val] || val || '-'
if (!val) return '-'
const key = `common.payment.${val.toLowerCase()}`
return te(key) ? t(key) : val
}
/** 分类下拉项渲染:图标 + 名称(下拉选项与已选项均生效) */
@@ -292,9 +295,9 @@ async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
await toggleRemarkHidden(row.id, hidden)
row.remarkHidden = hidden
concealRemark(row.id)
message.success(hidden ? '已隐藏备注' : '已取消隐藏')
message.success(hidden ? t('transaction.remarkHiddenSuccess') : t('transaction.remarkShownSuccess'))
} catch (e: any) {
message.error(e.message || '操作失败')
message.error(e.message || t('common.operationFailed'))
}
}
@@ -302,31 +305,31 @@ async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
function createColumns(): DataTableColumns<TransactionItem> {
return [
{
title: '交易时间', key: 'transactionTime', width: 170,
title: t('transaction.column.transactionTime'), key: 'transactionTime', width: 170,
render(row) {
const time = row.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
const created = row.createdAt?.replace('T', ' ').substring(0, 16)
return h('span', { title: `创建时间:${created || '-'}` }, time)
return h('span', { title: t('transaction.createdTime', { time: created || '-' }) }, time)
},
},
{
title: '类型', key: 'type', width: 90,
title: t('transaction.column.type'), key: 'type', width: 90,
render(row) {
if (!row.includeInStats) {
return h(NTag, { type: 'default', size: 'small' }, { default: () => '不计入' })
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' ? '收入' : '支出' })
return h(NTag, { type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' }, { default: () => row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense') })
},
},
{
title: '分类', key: 'categoryName', width: 120,
title: t('transaction.column.category'), key: 'categoryName', width: 120,
render(row) {
const name = row.categoryName || '未分类'
const name = row.categoryName || t('transaction.uncategorized')
return row.categoryIcon ? `${row.categoryIcon} ${name}` : name
},
},
{
title: '金额', key: 'amount', width: 120,
title: t('transaction.column.amount'), key: 'amount', width: 120,
render(row) {
if (!row.includeInStats) {
return h('span', { style: { color: '#999', fontWeight: 'bold' } }, `${row.amount.toFixed(2)}`)
@@ -336,34 +339,34 @@ function createColumns(): DataTableColumns<TransactionItem> {
},
},
{
title: '支付方式', key: 'paymentMethod', width: 100,
title: t('transaction.column.paymentMethod'), key: 'paymentMethod', width: 100,
render(row) { return getPaymentLabel(row.paymentMethod) },
},
{
title: '备注', key: 'remark', minWidth: 220,
title: t('transaction.column.remark'), key: 'remark', minWidth: 220,
render(row) {
const hidden = !!row.remarkHidden
const collapsed = hidden && !revealedRemarkIds.value.includes(row.id)
const nodes: any[] = [
h('span', {
class: 'remark-text',
title: collapsed ? '备注已隐藏' : (row.remark || ''),
title: collapsed ? t('transaction.remarkHidden') : (row.remark || ''),
}, collapsed ? '******' : (row.remark || '-')),
]
if (collapsed) {
nodes.push(renderRemarkAction('查看', EyeOutline, () => revealRemark(row.id)))
nodes.push(renderRemarkAction('取消隐藏', EyeOffOutline, () => setRemarkHidden(row, false)))
nodes.push(renderRemarkAction(t('transaction.remarkView'), EyeOutline, () => revealRemark(row.id)))
nodes.push(renderRemarkAction(t('transaction.remarkUnhide'), EyeOffOutline, () => setRemarkHidden(row, false)))
} else if (hidden) {
nodes.push(renderRemarkAction('收起', EyeOffOutline, () => concealRemark(row.id)))
nodes.push(renderRemarkAction('取消隐藏', EyeOutline, () => setRemarkHidden(row, false)))
nodes.push(renderRemarkAction(t('transaction.remarkCollapse'), EyeOffOutline, () => concealRemark(row.id)))
nodes.push(renderRemarkAction(t('transaction.remarkUnhide'), EyeOutline, () => setRemarkHidden(row, false)))
} else {
nodes.push(renderRemarkAction('隐藏', EyeOffOutline, () => setRemarkHidden(row, true)))
nodes.push(renderRemarkAction(t('transaction.remarkHide'), EyeOffOutline, () => setRemarkHidden(row, true)))
}
return h('div', { class: 'remark-cell' }, nodes)
},
},
{
title: '标签', key: 'tags', minWidth: 140,
title: t('transaction.column.tags'), key: 'tags', minWidth: 140,
render(row) {
if (!row.tags || row.tags.length === 0) return '-'
return h(NSpace, { size: 4 }, {
@@ -373,7 +376,7 @@ function createColumns(): DataTableColumns<TransactionItem> {
},
},
{
title: '计入统计', key: 'includeInStats', width: 100, align: 'center',
title: t('transaction.column.includeInStats'), key: 'includeInStats', width: 100, align: 'center',
render(row) {
return h(NSwitch, {
value: row.includeInStats,
@@ -382,13 +385,13 @@ function createColumns(): DataTableColumns<TransactionItem> {
},
},
{
title: '操作', key: 'actions', width: 240,
title: t('transaction.column.actions'), key: 'actions', width: 240,
render(row) {
return h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
h(NButton, { size: 'small', disabled: !row.rawData, onClick: () => handleViewRaw(row) }, { default: () => '原始数据' }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
h(NButton, { size: 'small', disabled: !row.rawData, onClick: () => handleViewRaw(row) }, { default: () => t('transaction.rawData') }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
],
})
},
@@ -396,7 +399,7 @@ function createColumns(): DataTableColumns<TransactionItem> {
]
}
const columns = createColumns() // 表格列配置
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
/** 加载分类列表 */
async function fetchCategories() {
@@ -442,7 +445,7 @@ async function fetchData() {
total.value = res.data.total
revealedRemarkIds.value = []
} catch (e: any) {
message.error(e.message || '加载失败')
message.error(e.message || t('common.loadFailed'))
} finally {
loading.value = false
}
@@ -457,7 +460,7 @@ watch(sortValue, () => {
/** 查看某条记录的原始账单数据(格式化为缩进 JSON) */
function handleViewRaw(row: TransactionItem) {
if (!row.rawData) {
message.warning('该记录没有原始账单数据')
message.warning(t('transaction.noRawData'))
return
}
try {
@@ -494,11 +497,11 @@ function handleEdit(row: TransactionItem) {
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
async function handleSubmit() {
if (!form.value.categoryId) {
message.warning('请选择分类')
message.warning(t('transaction.selectCategory'))
return
}
if (!form.value.amount || form.value.amount <= 0) {
message.warning('请输入有效金额')
message.warning(t('transaction.validAmount'))
return
}
const data = {
@@ -514,32 +517,32 @@ async function handleSubmit() {
try {
if (editingId.value) {
await updateTransaction(editingId.value, data)
message.success('修改成功')
message.success(t('common.updateSuccess'))
} else {
await createTransaction(data)
message.success('创建成功')
message.success(t('common.createSuccess'))
}
showModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || '操作失败')
message.error(e.message || t('common.operationFailed'))
}
}
/** 删除记录:弹出确认对话框,确认后执行删除并刷新列表 */
function handleDelete(id: number) {
dialog.warning({
title: '确认删除',
content: '确定要删除这条记录吗?',
positiveText: '删除',
negativeText: '取消',
title: t('common.confirmDelete'),
content: t('transaction.deleteConfirm'),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await deleteTransaction(id)
message.success('删除成功')
message.success(t('common.deleteSuccess'))
await fetchData()
} catch (e: any) {
message.error(e.message || '删除失败')
message.error(e.message || t('common.deleteFailed'))
}
},
})
@@ -584,16 +587,16 @@ async function handleToggleStats(row: TransactionItem, value: boolean) {
try {
await toggleTransactionStats(row.id, value)
row.includeInStats = value
message.success(value ? '已计入统计' : '已排除统计')
message.success(value ? t('transaction.statsIncluded') : t('transaction.statsExcluded'))
} catch (e: any) {
message.error(e.message || '操作失败')
message.error(e.message || t('common.operationFailed'))
}
}
/** 打开批量设置分类弹窗 */
function handleBatchOpen() {
if (transactions.value.length === 0) {
message.warning('当前表格没有可设置的记录')
message.warning(t('transaction.noRecordsToSet'))
return
}
batchExpenseCategoryId.value = null
@@ -609,7 +612,7 @@ async function handleBatchSubmit() {
const hasCategoryOrStats = !!(batchExpenseCategoryId.value || batchIncomeCategoryId.value || batchIncludeInStats.value !== 'null')
const hasTags = batchAddTagIds.value.length > 0 || batchRemoveTagIds.value.length > 0
if (!hasCategoryOrStats && !hasTags) {
message.warning('请至少选择分类、计入统计或标签')
message.warning(t('transaction.batchNothingSelected'))
return
}
batching.value = true
@@ -630,11 +633,11 @@ async function handleBatchSubmit() {
removeTagIds: batchRemoveTagIds.value,
})
}
message.success(`已更新 ${ids.length} 条记录`)
message.success(t('transaction.batchUpdated', { count: ids.length }))
showBatchModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || '操作失败')
message.error(e.message || t('common.operationFailed'))
} finally {
batching.value = false
}
@@ -661,14 +664,14 @@ async function handleExport() {
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `交易记录_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.xlsx`
link.download = `${t('transaction.exportFileName')}_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('导出成功')
message.success(t('transaction.exportSuccess'))
} catch (e: any) {
message.error(e.message || '导出失败')
message.error(e.message || t('transaction.exportFailed'))
}
}
@@ -685,14 +688,14 @@ onMounted(() => {
<div>
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px;">
<h2 style="margin: 0;">收支记录</h2>
<h2 style="margin: 0;">{{ t('transaction.title') }}</h2>
<NSpace align="center">
<span style="color: #999; font-size: 13px;">排序:</span>
<span style="color: #999; font-size: 13px;">{{ t('transaction.sortLabel') }}</span>
<NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" />
<NButton @click="handleBatchOpen">一键设置</NButton>
<NButton @click="showImportModal = true">导入账单</NButton>
<NButton @click="handleExport">导出 Excel</NButton>
<NButton type="primary" @click="handleAdd">新增记录</NButton>
<NButton @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton>
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
<NButton @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton type="primary" @click="handleAdd">{{ t('transaction.addRecord') }}</NButton>
</NSpace>
</NSpace>
@@ -705,7 +708,7 @@ onMounted(() => {
multiple
clearable
:max-tag-count="2"
placeholder="类型"
:placeholder="t('transaction.column.type')"
style="width: 160px;"
/>
<NSelect
@@ -717,7 +720,7 @@ onMounted(() => {
clearable
filterable
:max-tag-count="2"
placeholder="分类"
:placeholder="t('transaction.column.category')"
style="width: 200px;"
/>
<NDatePicker
@@ -729,14 +732,14 @@ onMounted(() => {
<NInput
v-model:value="filterRemark"
clearable
placeholder="备注搜索"
:placeholder="t('transaction.column.remark')"
style="width: 180px;"
@keyup.enter="handleSearch"
/>
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
<template #trigger>
<NButton>
更多筛选
{{ t('transaction.moreFilter') }}
<NTag v-if="moreFilterCount > 0" :bordered="false" size="small" type="primary" style="margin-left: 6px;">
{{ moreFilterCount }}
</NTag>
@@ -744,19 +747,19 @@ onMounted(() => {
</template>
<NSpace vertical :size="12" style="width: 260px;">
<div>
<div class="filter-label">支付方式</div>
<div class="filter-label">{{ t('transaction.column.paymentMethod') }}</div>
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
multiple
clearable
:max-tag-count="2"
placeholder="全部"
:placeholder="t('common.all')"
style="width: 100%;"
/>
</div>
<div>
<div class="filter-label">标签</div>
<div class="filter-label">{{ t('transaction.column.tags') }}</div>
<NSelect
v-model:value="filterTag"
:options="tagFilterOptions"
@@ -764,29 +767,29 @@ onMounted(() => {
clearable
filterable
:max-tag-count="2"
placeholder="全部"
:placeholder="t('common.all')"
style="width: 100%;"
/>
</div>
<div>
<div class="filter-label">计入统计</div>
<div class="filter-label">{{ t('transaction.column.includeInStats') }}</div>
<NSelect
v-model:value="filterIncludeInStats"
:options="includeInStatsOptions"
clearable
placeholder="全部"
:placeholder="t('common.all')"
style="width: 100%;"
/>
</div>
<div>
<div class="filter-label">金额区间</div>
<div class="filter-label">{{ t('transaction.amountRange') }}</div>
<NSpace align="center" :size="8">
<NInputNumber
v-model:value="filterMinAmount"
:min="0"
:precision="2"
clearable
placeholder="最小"
:placeholder="t('common.min')"
style="width: 110px;"
/>
<span style="color: #999;">~</span>
@@ -795,30 +798,30 @@ onMounted(() => {
:min="0"
:precision="2"
clearable
placeholder="最大"
:placeholder="t('common.max')"
style="width: 110px;"
/>
</NSpace>
</div>
<NSpace justify="end">
<NButton size="small" @click="clearMoreFilters">清空</NButton>
<NButton size="small" @click="clearMoreFilters">{{ t('transaction.clear') }}</NButton>
</NSpace>
</NSpace>
</NPopover>
<NButton type="primary" @click="handleSearch">查询</NButton>
<NButton @click="handleReset">重置</NButton>
<NButton type="primary" @click="handleSearch">{{ t('transaction.search') }}</NButton>
<NButton @click="handleReset">{{ t('transaction.reset') }}</NButton>
</NSpace>
</NCard>
<!-- 表格信息栏(右上角) -->
<NSpace justify="end" align="center" :size="12" style="margin-bottom: 8px;">
<span style="color: #999; font-size: 12px;">
总计 {{ total }} 条 · 第 {{ page }} / {{ totalPages }} 页 · 每页 {{ pageSize }} 条
{{ t('transaction.summary', { total, page, pages: totalPages, size: pageSize }) }}
</span>
<span style="font-size: 12px; color: #999;">
本页支出 <span style="color: #d03050;">{{ pageSummary.expense.toFixed(2) }}</span>
· 本页收入 <span style="color: #18a058;">{{ pageSummary.income.toFixed(2) }}</span>
· 本页结余 <span :style="{ color: pageSummary.balance < 0 ? '#d03050' : '#18a058' }">{{ pageSummary.balance.toFixed(2) }}</span>
{{ t('transaction.pageExpense') }} <span style="color: #d03050;">{{ pageSummary.expense.toFixed(2) }}</span>
· {{ t('transaction.pageIncome') }} <span style="color: #18a058;">{{ pageSummary.income.toFixed(2) }}</span>
· {{ t('transaction.pageBalance') }} <span :style="{ color: pageSummary.balance < 0 ? '#d03050' : '#18a058' }">{{ pageSummary.balance.toFixed(2) }}</span>
</span>
</NSpace>
@@ -840,41 +843,41 @@ onMounted(() => {
</NSpace>
<!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? '编辑记录' : '新增记录'" style="width: 500px;">
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('transaction.editRecord') : t('transaction.addRecordTitle')" style="width: 500px;">
<NForm>
<NFormItem label="类型">
<NFormItem :label="t('transaction.column.type')">
<NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem>
<NFormItem label="分类">
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" placeholder="请选择分类" filterable />
<NFormItem :label="t('transaction.column.category')">
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" :placeholder="t('transaction.selectCategoryPlaceholder')" filterable />
</NFormItem>
<NFormItem label="金额">
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" placeholder="请输入金额" />
<NFormItem :label="t('transaction.column.amount')">
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
</NFormItem>
<NFormItem label="支付方式">
<NSelect v-model:value="form.paymentMethod" :options="paymentOptions" clearable placeholder="选择支付方式" />
<NFormItem :label="t('transaction.column.paymentMethod')">
<NSelect v-model:value="form.paymentMethod" :options="paymentOptions" clearable :placeholder="t('transaction.paymentPlaceholder')" />
</NFormItem>
<NFormItem label="交易时间">
<NFormItem :label="t('transaction.column.transactionTime')">
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />
</NFormItem>
<NFormItem label="备注">
<NInput v-model:value="form.remark" type="textarea" placeholder="可选备注说明" />
<NFormItem :label="t('transaction.column.remark')">
<NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" />
</NFormItem>
<NFormItem label="标签">
<NFormItem :label="t('transaction.column.tags')">
<NSelect
v-model:value="form.tagIds"
:options="tagFilterOptions"
multiple
clearable
filterable
placeholder="选择标签"
:placeholder="t('transaction.tagPlaceholder')"
/>
</NFormItem>
<NFormItem label="计入统计">
<NFormItem :label="t('transaction.includeInStats')">
<NSwitch v-model:value="form.includeInStats" />
<span style="color: #999; margin-left: 8px; font-size: 12px;">关闭后不纳入月度统计(如借款、转账等)</span>
<span style="color: #999; margin-left: 8px; font-size: 12px;">{{ t('transaction.includeInStatsHint') }}</span>
</NFormItem>
<NButton type="primary" block @click="handleSubmit">确定</NButton>
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
@@ -882,48 +885,48 @@ onMounted(() => {
<BillImportModal v-model:show="showImportModal" @imported="fetchData" />
<!-- 原始账单数据弹窗 -->
<NModal v-model:show="showRawModal" preset="card" title="原始账单数据" style="width: 640px;">
<NModal v-model:show="showRawModal" preset="card" :title="t('transaction.rawDataTitle')" style="width: 640px;">
<NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" />
</NModal>
<!-- 一键设置弹窗(作用于当前表格展示的记录) -->
<NModal v-model:show="showBatchModal" preset="card" title="一键设置分类与统计" style="width: 480px;">
<NModal v-model:show="showBatchModal" preset="card" :title="t('transaction.batchTitle')" style="width: 480px;">
<NForm>
<NFormItem label="作用范围">
<NFormItem :label="t('transaction.batchScope')">
<span style="color: #999; font-size: 12px;">
将对当前表格展示的 {{ transactions.length }} 条记录设置分类与计入统计
{{ t('transaction.batchScopeHint', { count: transactions.length }) }}
</span>
</NFormItem>
<NFormItem label="支出分类">
<NFormItem :label="t('transaction.expenseCategory')">
<NSelect
v-model:value="batchExpenseCategoryId"
:options="categories.filter(c => c.type === 'EXPENSE')"
:render-label="renderCategoryLabel"
filterable
clearable
placeholder="不设置支出分类"
:placeholder="t('transaction.notSetExpense')"
/>
</NFormItem>
<NFormItem label="收入分类">
<NFormItem :label="t('transaction.incomeCategory')">
<NSelect
v-model:value="batchIncomeCategoryId"
:options="categories.filter(c => c.type === 'INCOME')"
:render-label="renderCategoryLabel"
filterable
clearable
placeholder="不设置收入分类"
:placeholder="t('transaction.notSetIncome')"
/>
</NFormItem>
<NFormItem label="计入统计">
<NFormItem :label="t('transaction.includeInStats')">
<NSelect v-model:value="batchIncludeInStats" :options="batchStatsOptions" />
</NFormItem>
<NFormItem label="添加标签">
<NSelect v-model:value="batchAddTagIds" :options="tagFilterOptions" multiple clearable filterable placeholder="不添加标签" />
<NFormItem :label="t('transaction.addTag')">
<NSelect v-model:value="batchAddTagIds" :options="tagFilterOptions" multiple clearable filterable :placeholder="t('transaction.notAddTag')" />
</NFormItem>
<NFormItem label="移除标签">
<NSelect v-model:value="batchRemoveTagIds" :options="tagFilterOptions" multiple clearable filterable placeholder="不移除标签" />
<NFormItem :label="t('transaction.removeTag')">
<NSelect v-model:value="batchRemoveTagIds" :options="tagFilterOptions" multiple clearable filterable :placeholder="t('transaction.notRemoveTag')" />
</NFormItem>
<NButton type="primary" block :loading="batching" @click="handleBatchSubmit">确定</NButton>
<NButton type="primary" block :loading="batching" @click="handleBatchSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
</div>