feat: 前端新增中英文多语言支持
- 引入 vue-i18n,新增 locales/messages.ts 以中英对照方式维护词条 - 新增 LocaleSwitch 组件,在登录/注册页与主布局顶部栏提供切换入口 - 语言持久化到 localStorage 并同步 <html lang>,Naive UI 组件语言联动 - 各视图、表格列、弹窗与提示文案全部接入国际化 - 修复 confirmImport 缺失的 tagIds 类型定义
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"vue": "^3.5.41",
|
"vue": "^3.5.41",
|
||||||
"vue-echarts": "^8.1.0",
|
"vue-echarts": "^8.1.0",
|
||||||
|
"vue-i18n": "^11.4.12",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+15
-5
@@ -1,12 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 根组件:提供 Naive UI 的全局配置(中文化)、消息提示和对话框上下文
|
// 根组件:提供 Naive UI 的全局配置(跟随应用语言)、消息提示和对话框上下文
|
||||||
import { NMessageProvider, NDialogProvider, NConfigProvider } from 'naive-ui'
|
import { computed } from 'vue'
|
||||||
import { zhCN, dateZhCN } from 'naive-ui' // 中文语言包和日期格式
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 全局配置:设置中文语言和日期格式 -->
|
<!-- 全局配置:设置组件语言和日期格式 -->
|
||||||
<NConfigProvider :locale="zhCN" :date-locale="dateZhCN">
|
<NConfigProvider :locale="naiveLocale" :date-locale="naiveDateLocale">
|
||||||
<!-- 提供全局消息提示能力 -->
|
<!-- 提供全局消息提示能力 -->
|
||||||
<NMessageProvider>
|
<NMessageProvider>
|
||||||
<!-- 提供全局对话框能力 -->
|
<!-- 提供全局对话框能力 -->
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export function confirmImport(data: {
|
|||||||
billType: BillType
|
billType: BillType
|
||||||
expenseCategoryId?: number | null
|
expenseCategoryId?: number | null
|
||||||
incomeCategoryId?: number | null
|
incomeCategoryId?: number | null
|
||||||
|
tagIds?: number[]
|
||||||
items: BillImportItem[]
|
items: BillImportItem[]
|
||||||
}) {
|
}) {
|
||||||
return request.post('/transaction/import/confirm', data)
|
return request.post('/transaction/import/confirm', data)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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' },
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import naive from 'naive-ui'
|
import naive from 'naive-ui'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import i18n from './locales'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
// 创建 Vue 应用实例
|
// 创建 Vue 应用实例
|
||||||
@@ -10,4 +11,5 @@ const app = createApp(App)
|
|||||||
app.use(createPinia()) // 注册 Pinia 状态管理
|
app.use(createPinia()) // 注册 Pinia 状态管理
|
||||||
app.use(router) // 注册路由
|
app.use(router) // 注册路由
|
||||||
app.use(naive) // 全局注册 Naive UI 组件库
|
app.use(naive) // 全局注册 Naive UI 组件库
|
||||||
|
app.use(i18n) // 注册国际化
|
||||||
app.mount('#app') // 挂载到 DOM 元素 #app
|
app.mount('#app') // 挂载到 DOM 元素 #app
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 账单导入弹窗:先解析、后勾选导入(默认全选),当前支持支付宝账单
|
// 账单导入弹窗:先解析、后勾选导入(默认全选),当前支持支付宝与微信账单
|
||||||
import { ref, computed, h, watch } from 'vue'
|
import { ref, computed, h, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import {
|
import {
|
||||||
useMessage, NModal, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
|
useMessage, NModal, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
|
||||||
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber,
|
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber,
|
||||||
@@ -19,15 +20,16 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const { t } = useI18n() // 国际化
|
||||||
|
|
||||||
// 账单类型偏好存储键
|
// 账单类型偏好存储键
|
||||||
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
|
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
|
||||||
|
|
||||||
// 账单类型选项,当前支持支付宝与微信
|
// 账单类型选项,当前支持支付宝与微信
|
||||||
const billTypeOptions = [
|
const billTypeOptions = computed(() => [
|
||||||
{ label: '支付宝', value: 'ALIPAY' },
|
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
|
||||||
{ label: '微信', value: 'WECHAT' },
|
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' },
|
||||||
]
|
])
|
||||||
|
|
||||||
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
|
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
|
||||||
function loadBillTypePref(): BillType {
|
function loadBillTypePref(): BillType {
|
||||||
@@ -44,23 +46,20 @@ const allowedExtensions: Record<BillType, string[]> = {
|
|||||||
WECHAT: ['.xlsx'],
|
WECHAT: ['.xlsx'],
|
||||||
}
|
}
|
||||||
|
|
||||||
// 各账单类型的账单导出路径提示
|
|
||||||
const exportHints: Record<BillType, string> = {
|
|
||||||
ALIPAY: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。',
|
|
||||||
WECHAT: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
|
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
|
||||||
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
|
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
|
||||||
|
|
||||||
// 当前账单类型的文件格式提示
|
// 当前账单类型的文件格式提示
|
||||||
const fileHint = computed(() => {
|
const fileHint = computed(() => {
|
||||||
const label = billTypeOptions.find((o) => o.value === billType.value)?.label || ''
|
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
|
||||||
return `仅支持${label}导出的账单文件(${(allowedExtensions[billType.value] || []).join('、')})`
|
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')
|
const stage = ref<'select' | 'preview' | 'result'>('select')
|
||||||
@@ -82,18 +81,18 @@ const filterMinAmount = ref<number | null>(null)
|
|||||||
const filterMaxAmount = ref<number | null>(null)
|
const filterMaxAmount = ref<number | null>(null)
|
||||||
|
|
||||||
// 类型筛选选项
|
// 类型筛选选项
|
||||||
const typeFilterOptions = [
|
const typeFilterOptions = computed(() => [
|
||||||
{ label: '收入', value: 'INCOME' },
|
{ label: t('import.typeFilter.income'), value: 'INCOME' },
|
||||||
{ label: '支出', value: 'EXPENSE' },
|
{ label: t('import.typeFilter.expense'), value: 'EXPENSE' },
|
||||||
{ label: '不计入', value: 'NEUTRAL' },
|
{ label: t('import.typeFilter.neutral'), value: 'NEUTRAL' },
|
||||||
]
|
])
|
||||||
|
|
||||||
// 状态筛选选项
|
// 状态筛选选项
|
||||||
const statusFilterOptions = [
|
const statusFilterOptions = computed(() => [
|
||||||
{ label: '可导入', value: 'importable' },
|
{ label: t('import.statusFilter.importable'), value: 'importable' },
|
||||||
{ label: '已存在', value: 'duplicate' },
|
{ label: t('import.statusFilter.duplicate'), value: 'duplicate' },
|
||||||
{ label: '不可导入', value: 'error' },
|
{ label: t('import.statusFilter.error'), value: 'error' },
|
||||||
]
|
])
|
||||||
|
|
||||||
/** 重置预览筛选条件 */
|
/** 重置预览筛选条件 */
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
@@ -152,7 +151,7 @@ const someChecked = computed(() => {
|
|||||||
return checkedVisible.length > 0 && !allChecked.value
|
return checkedVisible.length > 0 && !allChecked.value
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 表格列定义 */
|
/** 表格列定义(随语言切换响应式更新) */
|
||||||
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
||||||
{
|
{
|
||||||
title: () =>
|
title: () =>
|
||||||
@@ -172,31 +171,31 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '交易时间',
|
title: t('import.column.transactionTime'),
|
||||||
key: 'transactionTime',
|
key: 'transactionTime',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
|
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: t('import.column.type'),
|
||||||
key: 'type',
|
key: 'type',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
if (!row.importable) {
|
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) {
|
if (!row.includeInStats) {
|
||||||
return h(NTag, { type: 'default', size: 'small' }, { default: () => '不计入' })
|
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('import.notIncluded') })
|
||||||
}
|
}
|
||||||
return h(
|
return h(
|
||||||
NTag,
|
NTag,
|
||||||
{ type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' },
|
{ 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',
|
key: 'amount',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (row) => {
|
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)}`)
|
return h('span', { style: { color, fontWeight: 'bold' } }, `${row.type === 'INCOME' ? '+' : '-'}${Number(row.amount).toFixed(2)}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '交易对方', key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
|
{ title: t('import.column.counterparty'), key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
|
||||||
{ title: '商品名称', key: 'product', ellipsis: { tooltip: true } },
|
{ title: t('import.column.product'), key: 'product', ellipsis: { tooltip: true } },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: t('import.column.status'),
|
||||||
key: 'tradeStatus',
|
key: 'tradeStatus',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
@@ -215,7 +214,7 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
|
|||||||
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
|
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
|
||||||
}
|
}
|
||||||
if (row.duplicate) {
|
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 || '-')
|
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 }))
|
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 }))
|
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id }))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '加载分类失败')
|
message.error(e.message || t('import.loadCategoriesFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,9 +236,9 @@ async function loadCategories() {
|
|||||||
async function loadTags() {
|
async function loadTags() {
|
||||||
try {
|
try {
|
||||||
const res: any = await getTagList()
|
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) {
|
} 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[] }) {
|
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
|
||||||
const last = options.fileList[options.fileList.length - 1]
|
const last = options.fileList[options.fileList.length - 1]
|
||||||
if (last?.file && !isAllowedFile(last.name)) {
|
if (last?.file && !isAllowedFile(last.name)) {
|
||||||
const label = billTypeOptions.find((o) => o.value === billType.value)?.label || billType.value
|
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
|
||||||
message.error(`账单类型「${label}」仅支持 ${allowedExtensions[billType.value].join('、')} 格式的文件`)
|
const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
|
||||||
|
message.error(t('import.errorFileType', { label, exts }))
|
||||||
uploadFileList.value = []
|
uploadFileList.value = []
|
||||||
file.value = null
|
file.value = null
|
||||||
return
|
return
|
||||||
@@ -267,7 +267,7 @@ function handleFileChange(options: { fileList: UploadFileInfo[] }) {
|
|||||||
/** 解析账单 */
|
/** 解析账单 */
|
||||||
async function handleParse() {
|
async function handleParse() {
|
||||||
if (!file.value) {
|
if (!file.value) {
|
||||||
message.warning('请先选择账单文件')
|
message.warning(t('import.selectFileFirst'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
parsing.value = true
|
parsing.value = true
|
||||||
@@ -279,7 +279,7 @@ async function handleParse() {
|
|||||||
resetFilters()
|
resetFilters()
|
||||||
stage.value = 'preview'
|
stage.value = 'preview'
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '解析失败')
|
message.error(e.message || t('import.parseFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
parsing.value = false
|
parsing.value = false
|
||||||
}
|
}
|
||||||
@@ -304,7 +304,7 @@ function toggleAll(checked: boolean) {
|
|||||||
/** 确认导入 */
|
/** 确认导入 */
|
||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
if (checkedRows.value.length === 0) {
|
if (checkedRows.value.length === 0) {
|
||||||
message.warning('请至少勾选一条记录')
|
message.warning(t('import.selectAtLeastOne'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const items: BillImportItem[] = checkedRows.value.map((r) => ({
|
const items: BillImportItem[] = checkedRows.value.map((r) => ({
|
||||||
@@ -330,7 +330,7 @@ async function handleConfirm() {
|
|||||||
stage.value = 'result'
|
stage.value = 'result'
|
||||||
emit('imported')
|
emit('imported')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '导入失败')
|
message.error(e.message || t('import.importFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
importing.value = false
|
importing.value = false
|
||||||
}
|
}
|
||||||
@@ -379,7 +379,7 @@ watch(
|
|||||||
<NModal
|
<NModal
|
||||||
:show="props.show"
|
:show="props.show"
|
||||||
preset="card"
|
preset="card"
|
||||||
title="导入账单"
|
:title="t('import.title')"
|
||||||
style="width: 960px; max-width: 92vw;"
|
style="width: 960px; max-width: 92vw;"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
@update:show="handleClose"
|
@update:show="handleClose"
|
||||||
@@ -388,12 +388,12 @@ watch(
|
|||||||
<template v-if="stage === 'select'">
|
<template v-if="stage === 'select'">
|
||||||
<NSpace vertical :size="16">
|
<NSpace vertical :size="16">
|
||||||
<NSpace align="center">
|
<NSpace align="center">
|
||||||
<NText>账单类型:</NText>
|
<NText>{{ t('import.billType') }}</NText>
|
||||||
<NSelect v-model:value="billType" :options="billTypeOptions" style="width: 160px;" />
|
<NSelect v-model:value="billType" :options="billTypeOptions" style="width: 160px;" />
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<NText>账单文件:</NText>
|
<NText>{{ t('import.billFile') }}</NText>
|
||||||
<NUpload
|
<NUpload
|
||||||
:file-list="uploadFileList"
|
:file-list="uploadFileList"
|
||||||
:default-upload="false"
|
:default-upload="false"
|
||||||
@@ -407,7 +407,7 @@ watch(
|
|||||||
<NIcon size="48" :depth="3">
|
<NIcon size="48" :depth="3">
|
||||||
<CloudUploadOutline />
|
<CloudUploadOutline />
|
||||||
</NIcon>
|
</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;">
|
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||||
{{ fileHint }}
|
{{ fileHint }}
|
||||||
</div>
|
</div>
|
||||||
@@ -421,8 +421,8 @@ watch(
|
|||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
<NSpace justify="end">
|
<NSpace justify="end">
|
||||||
<NButton @click="handleClose">取消</NButton>
|
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||||
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">解析</NButton>
|
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">{{ t('import.parse') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</template>
|
</template>
|
||||||
@@ -431,25 +431,25 @@ watch(
|
|||||||
<template v-else-if="stage === 'preview'">
|
<template v-else-if="stage === 'preview'">
|
||||||
<NSpace vertical :size="12">
|
<NSpace vertical :size="12">
|
||||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||||
<NText>支出默认分类(可不选):</NText>
|
<NText>{{ t('import.expenseDefaultCategory') }}</NText>
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="expenseCategoryId"
|
v-model:value="expenseCategoryId"
|
||||||
:options="expenseCategories"
|
:options="expenseCategories"
|
||||||
filterable
|
filterable
|
||||||
clearable
|
clearable
|
||||||
placeholder="未选则不计分类"
|
:placeholder="t('import.notSetCategory')"
|
||||||
style="width: 180px;"
|
style="width: 180px;"
|
||||||
/>
|
/>
|
||||||
<NText>收入默认分类(可不选):</NText>
|
<NText>{{ t('import.incomeDefaultCategory') }}</NText>
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="incomeCategoryId"
|
v-model:value="incomeCategoryId"
|
||||||
:options="incomeCategories"
|
:options="incomeCategories"
|
||||||
filterable
|
filterable
|
||||||
clearable
|
clearable
|
||||||
placeholder="未选则不计分类"
|
:placeholder="t('import.notSetCategory')"
|
||||||
style="width: 180px;"
|
style="width: 180px;"
|
||||||
/>
|
/>
|
||||||
<NText>默认标签(可不选):</NText>
|
<NText>{{ t('import.defaultTags') }}</NText>
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="importTagIds"
|
v-model:value="importTagIds"
|
||||||
:options="allTags"
|
:options="allTags"
|
||||||
@@ -457,7 +457,7 @@ watch(
|
|||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
:max-tag-count="2"
|
:max-tag-count="2"
|
||||||
placeholder="未选则不打标签"
|
:placeholder="t('import.notSetTag')"
|
||||||
style="width: 200px;"
|
style="width: 200px;"
|
||||||
/>
|
/>
|
||||||
<NCheckbox
|
<NCheckbox
|
||||||
@@ -466,20 +466,20 @@ watch(
|
|||||||
:disabled="selectableRows.length === 0"
|
:disabled="selectableRows.length === 0"
|
||||||
@update:checked="toggleAll"
|
@update:checked="toggleAll"
|
||||||
>
|
>
|
||||||
全选
|
{{ t('import.selectAll') }}
|
||||||
</NCheckbox>
|
</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>
|
||||||
|
|
||||||
<!-- 筛选条件 -->
|
<!-- 筛选条件 -->
|
||||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||||
<NInput v-model:value="filterKeyword" clearable placeholder="搜索交易对方/商品/单号" style="width: 220px;" />
|
<NInput v-model:value="filterKeyword" clearable :placeholder="t('import.searchPlaceholder')" style="width: 220px;" />
|
||||||
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable placeholder="类型" style="width: 120px;" />
|
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable :placeholder="t('import.typePlaceholder')" style="width: 120px;" />
|
||||||
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable placeholder="状态" style="width: 130px;" />
|
<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="最小金额" style="width: 120px;" />
|
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable :placeholder="t('common.min')" style="width: 120px;" />
|
||||||
<span style="color: #999;">~</span>
|
<span style="color: #999;">~</span>
|
||||||
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable placeholder="最大金额" style="width: 120px;" />
|
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable :placeholder="t('common.max')" style="width: 120px;" />
|
||||||
<NButton size="small" @click="resetFilters">重置</NButton>
|
<NButton size="small" @click="resetFilters">{{ t('common.reset') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
<NDataTable
|
<NDataTable
|
||||||
@@ -492,9 +492,9 @@ watch(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<NSpace justify="end">
|
<NSpace justify="end">
|
||||||
<NButton @click="stage = 'select'">上一步</NButton>
|
<NButton @click="stage = 'select'">{{ t('import.prevStep') }}</NButton>
|
||||||
<NButton type="primary" :loading="importing" @click="handleConfirm">
|
<NButton type="primary" :loading="importing" @click="handleConfirm">
|
||||||
确认导入({{ checkedRows.length }})
|
{{ t('import.confirmImport', { count: checkedRows.length }) }}
|
||||||
</NButton>
|
</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
@@ -504,7 +504,7 @@ watch(
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<NSpace vertical :size="12">
|
<NSpace vertical :size="12">
|
||||||
<NAlert type="success" :show-icon="true">
|
<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>
|
</NAlert>
|
||||||
<div v-if="result?.messages?.length" style="max-height: 320px; overflow: auto;">
|
<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;">
|
<NText v-for="(msg, i) in result.messages" :key="i" depth="3" style="display: block; font-size: 12px;">
|
||||||
@@ -512,7 +512,7 @@ watch(
|
|||||||
</NText>
|
</NText>
|
||||||
</div>
|
</div>
|
||||||
<NSpace justify="end">
|
<NSpace justify="end">
|
||||||
<NButton type="primary" @click="handleClose">完成</NButton>
|
<NButton type="primary" @click="handleClose">{{ t('import.done') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 分类管理组件:展示收入/支出分类列表,支持新增、编辑、删除分类
|
// 分类管理组件:展示收入/支出分类列表,支持新增、编辑、删除分类
|
||||||
import { ref, onMounted, computed, 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, NSelect } from 'naive-ui'
|
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||||
import type { DataTableColumns } from 'naive-ui'
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
import { getCategoryList, createCategory, updateCategory, deleteCategory } from '../api/category'
|
import { getCategoryList, createCategory, updateCategory, deleteCategory } from '../api/category'
|
||||||
|
|
||||||
const message = useMessage() // 消息提示
|
const message = useMessage() // 消息提示
|
||||||
const dialog = useDialog() // 对话框
|
const dialog = useDialog() // 对话框
|
||||||
|
const { t } = useI18n() // 国际化
|
||||||
|
|
||||||
/** 分类数据结构 */
|
/** 分类数据结构 */
|
||||||
interface CategoryItem {
|
interface CategoryItem {
|
||||||
@@ -26,10 +28,10 @@ const editingId = ref<number | null>(null) // 当前编辑的分类 ID(null
|
|||||||
const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据
|
const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据
|
||||||
|
|
||||||
// 类型选项
|
// 类型选项
|
||||||
const typeOptions = [
|
const typeOptions = computed(() => [
|
||||||
{ label: '支出', value: 'EXPENSE' },
|
{ label: t('common.types.expense'), value: 'EXPENSE' },
|
||||||
{ label: '收入', value: 'INCOME' },
|
{ label: t('common.types.income'), value: 'INCOME' },
|
||||||
]
|
])
|
||||||
|
|
||||||
// 按类型过滤分类列表
|
// 按类型过滤分类列表
|
||||||
const incomeList = computed(() => categories.value.filter(c => c.type === '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> {
|
function createColumns(): DataTableColumns<CategoryItem> {
|
||||||
return [
|
return [
|
||||||
{ title: '名称', key: 'name' },
|
{ title: t('common.name'), key: 'name' },
|
||||||
{ title: '图标', key: 'icon' },
|
{ title: t('category.icon'), key: 'icon' },
|
||||||
{ title: '排序', key: 'sortOrder', width: 80 },
|
{ title: t('common.sort'), key: 'sortOrder', width: 80 },
|
||||||
{
|
{
|
||||||
title: '操作', key: 'actions', width: 180,
|
title: t('common.operation'), key: 'actions', width: 180,
|
||||||
render(row) {
|
render(row) {
|
||||||
// 编辑和删除按钮
|
// 编辑和删除按钮
|
||||||
return [
|
return [
|
||||||
h(NSpace, null, {
|
h(NSpace, null, {
|
||||||
default: () => [
|
default: () => [
|
||||||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
|
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
|
||||||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
|
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() {
|
async function fetchData() {
|
||||||
@@ -67,7 +69,7 @@ async function fetchData() {
|
|||||||
const res: any = await getCategoryList()
|
const res: any = await getCategoryList()
|
||||||
categories.value = res.data
|
categories.value = res.data
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '加载失败')
|
message.error(e.message || t('common.loadFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -90,40 +92,40 @@ function handleEdit(row: CategoryItem) {
|
|||||||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!form.value.name) {
|
if (!form.value.name) {
|
||||||
message.warning('请输入分类名称')
|
message.warning(t('category.nameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
// 更新已有分类
|
// 更新已有分类
|
||||||
await updateCategory(editingId.value, form.value)
|
await updateCategory(editingId.value, form.value)
|
||||||
message.success('修改成功')
|
message.success(t('common.updateSuccess'))
|
||||||
} else {
|
} else {
|
||||||
// 创建新分类
|
// 创建新分类
|
||||||
await createCategory(form.value)
|
await createCategory(form.value)
|
||||||
message.success('创建成功')
|
message.success(t('common.createSuccess'))
|
||||||
}
|
}
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
await fetchData() // 刷新列表
|
await fetchData() // 刷新列表
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '操作失败')
|
message.error(e.message || t('common.operationFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除分类:弹出确认对话框,确认后执行删除并刷新列表 */
|
/** 删除分类:弹出确认对话框,确认后执行删除并刷新列表 */
|
||||||
function handleDelete(id: number) {
|
function handleDelete(id: number) {
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
title: '确认删除',
|
title: t('common.confirmDelete'),
|
||||||
content: '确定要删除这个分类吗?',
|
content: t('category.deleteConfirm'),
|
||||||
positiveText: '删除',
|
positiveText: t('common.delete'),
|
||||||
negativeText: '取消',
|
negativeText: t('common.cancel'),
|
||||||
onPositiveClick: async () => {
|
onPositiveClick: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteCategory(id)
|
await deleteCategory(id)
|
||||||
message.success('删除成功')
|
message.success(t('common.deleteSuccess'))
|
||||||
await fetchData()
|
await fetchData()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '删除失败')
|
message.error(e.message || t('common.deleteFailed'))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -137,34 +139,34 @@ onMounted(fetchData)
|
|||||||
<div>
|
<div>
|
||||||
<!-- 顶部:标题和新增按钮 -->
|
<!-- 顶部:标题和新增按钮 -->
|
||||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||||
<h2 style="margin: 0;">分类管理</h2>
|
<h2 style="margin: 0;">{{ t('category.title') }}</h2>
|
||||||
<NButton type="primary" @click="handleAdd">新增分类</NButton>
|
<NButton type="primary" @click="handleAdd">{{ t('category.add') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
<!-- 支出分类列表 -->
|
<!-- 支出分类列表 -->
|
||||||
<h3>支出分类</h3>
|
<h3>{{ t('category.expenseSection') }}</h3>
|
||||||
<NDataTable :columns="columns" :data="expenseList" :loading="loading" :bordered="false" />
|
<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" />
|
<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>
|
<NForm>
|
||||||
<NFormItem label="分类名称">
|
<NFormItem :label="t('category.name')">
|
||||||
<NInput v-model:value="form.name" placeholder="请输入分类名称" />
|
<NInput v-model:value="form.name" :placeholder="t('category.namePlaceholder')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="类型">
|
<NFormItem :label="t('category.type')">
|
||||||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="图标">
|
<NFormItem :label="t('category.icon')">
|
||||||
<NInput v-model:value="form.icon" placeholder="可选,如 emoji" />
|
<NInput v-model:value="form.icon" :placeholder="t('category.iconPlaceholder')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="排序">
|
<NFormItem :label="t('category.sort')">
|
||||||
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NButton type="primary" block @click="handleSubmit">确定</NButton>
|
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
||||||
</NForm>
|
</NForm>
|
||||||
</NModal>
|
</NModal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与支付方式分布
|
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与支付方式分布
|
||||||
import { ref, computed, onMounted, watch, nextTick, h } from 'vue'
|
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 { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
|
||||||
import VChart from 'vue-echarts'
|
import VChart from 'vue-echarts'
|
||||||
import { use } from 'echarts/core'
|
import { use } from 'echarts/core'
|
||||||
@@ -13,21 +14,23 @@ import { getCategoryList } from '../api/category'
|
|||||||
// 注册 ECharts 所需组件
|
// 注册 ECharts 所需组件
|
||||||
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
|
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
|
||||||
|
|
||||||
|
const { t, locale } = useI18n() // 国际化
|
||||||
|
|
||||||
// 时间范围快捷选项
|
// 时间范围快捷选项
|
||||||
const rangeOptions = [
|
const rangeOptions = computed(() => [
|
||||||
{ label: '本月', value: 'month' },
|
{ label: t('dashboard.range.month'), value: 'month' },
|
||||||
{ label: '近3月', value: '3month' },
|
{ label: t('dashboard.range.3month'), value: '3month' },
|
||||||
{ label: '近12月', value: '12month' },
|
{ label: t('dashboard.range.12month'), value: '12month' },
|
||||||
{ label: '自定义', value: 'custom' },
|
{ label: t('dashboard.range.custom'), value: 'custom' },
|
||||||
]
|
])
|
||||||
// 趋势图时间粒度选项
|
// 趋势图时间粒度选项
|
||||||
const granularityOptions = [
|
const granularityOptions = computed(() => [
|
||||||
{ label: '按日', value: 'day' },
|
{ label: t('dashboard.granularity.day'), value: 'day' },
|
||||||
{ label: '按周', value: 'week' },
|
{ label: t('dashboard.granularity.week'), value: 'week' },
|
||||||
{ label: '按月', value: 'month' },
|
{ label: t('dashboard.granularity.month'), value: 'month' },
|
||||||
{ label: '按季', value: 'quarter' },
|
{ label: t('dashboard.granularity.quarter'), value: 'quarter' },
|
||||||
{ label: '按年', value: 'year' },
|
{ label: t('dashboard.granularity.year'), value: 'year' },
|
||||||
]
|
])
|
||||||
|
|
||||||
const rangeType = ref<'month' | '3month' | '12month' | 'custom'>('month') // 时间范围类型
|
const rangeType = ref<'month' | '3month' | '12month' | 'custom'>('month') // 时间范围类型
|
||||||
const customRange = ref<[number, number] | null>(null) // 自定义起止时间戳
|
const customRange = ref<[number, number] | null>(null) // 自定义起止时间戳
|
||||||
@@ -42,16 +45,16 @@ const filterMaxAmount = ref<number | null>(null) // 最大金额
|
|||||||
const filterRemark = ref('') // 备注关键字
|
const filterRemark = ref('') // 备注关键字
|
||||||
|
|
||||||
// 类型与支付方式选项
|
// 类型与支付方式选项
|
||||||
const typeOptions = [
|
const typeOptions = computed(() => [
|
||||||
{ label: '支出', value: 'EXPENSE' },
|
{ label: t('common.types.expense'), value: 'EXPENSE' },
|
||||||
{ label: '收入', value: 'INCOME' },
|
{ label: t('common.types.income'), value: 'INCOME' },
|
||||||
]
|
])
|
||||||
const paymentOptions = [
|
const paymentOptions = computed(() => [
|
||||||
{ label: '微信', value: 'WECHAT' },
|
{ label: t('common.payment.wechat'), value: 'WECHAT' },
|
||||||
{ label: '支付宝', value: 'ALIPAY' },
|
{ label: t('common.payment.alipay'), value: 'ALIPAY' },
|
||||||
{ label: '现金', value: 'CASH' },
|
{ label: t('common.payment.cash'), value: 'CASH' },
|
||||||
{ label: '银行卡', value: 'BANK' },
|
{ label: t('common.payment.bank'), value: 'BANK' },
|
||||||
]
|
])
|
||||||
|
|
||||||
/** 分类选项数据结构 */
|
/** 分类选项数据结构 */
|
||||||
interface CategoryOption {
|
interface CategoryOption {
|
||||||
@@ -64,7 +67,7 @@ interface CategoryOption {
|
|||||||
const categories = ref<CategoryOption[]>([]) // 分类选项列表
|
const categories = ref<CategoryOption[]>([]) // 分类选项列表
|
||||||
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
|
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
|
||||||
const categoryFilterOptions = computed(() => [
|
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)),
|
...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() {
|
function updatePieChart() {
|
||||||
pieOption.value = {
|
pieOption.value = {
|
||||||
title: { text: '支出分类占比', left: 'center' },
|
title: { text: t('dashboard.expenseCategoryPie'), left: 'center' },
|
||||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@@ -197,7 +200,7 @@ function updatePieChart() {
|
|||||||
/** 更新支付方式分布饼图 */
|
/** 更新支付方式分布饼图 */
|
||||||
function updatePaymentChart() {
|
function updatePaymentChart() {
|
||||||
paymentOption.value = {
|
paymentOption.value = {
|
||||||
title: { text: '支付方式分布', left: 'center' },
|
title: { text: t('dashboard.paymentPie'), left: 'center' },
|
||||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@@ -222,13 +225,13 @@ function updateTrendChart() {
|
|||||||
|
|
||||||
trendOption.value = {
|
trendOption.value = {
|
||||||
tooltip: { trigger: 'axis' },
|
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 },
|
grid: { left: 60, right: 20, top: 40, bottom: 40 },
|
||||||
xAxis: { type: 'category', data: labels },
|
xAxis: { type: 'category', data: labels },
|
||||||
yAxis: { type: 'value' },
|
yAxis: { type: 'value' },
|
||||||
series: [
|
series: [
|
||||||
{ name: '收入', type: 'bar', data: income, color: '#18a058' },
|
{ name: t('common.types.income'), type: 'bar', data: income, color: '#18a058' },
|
||||||
{ name: '支出', type: 'bar', data: expense, color: '#d03050' },
|
{ 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([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData)
|
||||||
// 任一偏好变化时自动保存
|
// 任一偏好变化时自动保存
|
||||||
watch([rangeType, customRange, granularity, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
|
watch([rangeType, customRange, granularity, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
|
||||||
|
// 语言切换时重绘图表,更新标题与图例文案
|
||||||
|
watch(locale, () => {
|
||||||
|
updatePieChart()
|
||||||
|
updatePaymentChart()
|
||||||
|
updateTrendChart()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadPrefs()
|
loadPrefs()
|
||||||
@@ -346,7 +355,7 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<!-- 顶部:标题与时间范围选择 -->
|
<!-- 顶部:标题与时间范围选择 -->
|
||||||
<NSpace justify="space-between" align="center" style="margin-bottom: 20px;" :wrap="true">
|
<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">
|
<NSpace align="center" :wrap="true">
|
||||||
<NSelect v-model:value="rangeType" :options="rangeOptions" style="width: 110px;" />
|
<NSelect v-model:value="rangeType" :options="rangeOptions" style="width: 110px;" />
|
||||||
<NDatePicker
|
<NDatePicker
|
||||||
@@ -369,7 +378,7 @@ onMounted(async () => {
|
|||||||
multiple
|
multiple
|
||||||
clearable
|
clearable
|
||||||
:max-tag-count="2"
|
:max-tag-count="2"
|
||||||
placeholder="类型"
|
:placeholder="t('dashboard.filterType')"
|
||||||
style="width: 160px;"
|
style="width: 160px;"
|
||||||
/>
|
/>
|
||||||
<NSelect
|
<NSelect
|
||||||
@@ -380,7 +389,7 @@ onMounted(async () => {
|
|||||||
filterable
|
filterable
|
||||||
:max-tag-count="2"
|
:max-tag-count="2"
|
||||||
:render-label="renderCategoryLabel"
|
:render-label="renderCategoryLabel"
|
||||||
placeholder="分类"
|
:placeholder="t('dashboard.filterCategory')"
|
||||||
style="width: 200px;"
|
style="width: 200px;"
|
||||||
/>
|
/>
|
||||||
<NSelect
|
<NSelect
|
||||||
@@ -389,7 +398,7 @@ onMounted(async () => {
|
|||||||
multiple
|
multiple
|
||||||
clearable
|
clearable
|
||||||
:max-tag-count="2"
|
:max-tag-count="2"
|
||||||
placeholder="支付方式"
|
:placeholder="t('dashboard.filterPaymentMethod')"
|
||||||
style="width: 200px;"
|
style="width: 200px;"
|
||||||
/>
|
/>
|
||||||
<NInputNumber
|
<NInputNumber
|
||||||
@@ -397,7 +406,7 @@ onMounted(async () => {
|
|||||||
:min="0"
|
:min="0"
|
||||||
:precision="2"
|
:precision="2"
|
||||||
clearable
|
clearable
|
||||||
placeholder="最小金额"
|
:placeholder="t('dashboard.minAmount')"
|
||||||
style="width: 130px;"
|
style="width: 130px;"
|
||||||
/>
|
/>
|
||||||
<span style="color: #999;">~</span>
|
<span style="color: #999;">~</span>
|
||||||
@@ -406,16 +415,16 @@ onMounted(async () => {
|
|||||||
:min="0"
|
:min="0"
|
||||||
:precision="2"
|
:precision="2"
|
||||||
clearable
|
clearable
|
||||||
placeholder="最大金额"
|
:placeholder="t('dashboard.maxAmount')"
|
||||||
style="width: 130px;"
|
style="width: 130px;"
|
||||||
/>
|
/>
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="filterRemark"
|
v-model:value="filterRemark"
|
||||||
clearable
|
clearable
|
||||||
placeholder="备注搜索"
|
:placeholder="t('dashboard.remark')"
|
||||||
style="width: 180px;"
|
style="width: 180px;"
|
||||||
/>
|
/>
|
||||||
<NButton @click="handleResetFilters">重置筛选</NButton>
|
<NButton @click="handleResetFilters">{{ t('dashboard.resetFilters') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
@@ -423,26 +432,26 @@ onMounted(async () => {
|
|||||||
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 16px;">
|
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 16px;">
|
||||||
<NGridItem>
|
<NGridItem>
|
||||||
<NCard>
|
<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>
|
<template #prefix>+</template>
|
||||||
</NStatistic>
|
</NStatistic>
|
||||||
</NCard>
|
</NCard>
|
||||||
</NGridItem>
|
</NGridItem>
|
||||||
<NGridItem>
|
<NGridItem>
|
||||||
<NCard>
|
<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>
|
<template #prefix>-</template>
|
||||||
</NStatistic>
|
</NStatistic>
|
||||||
</NCard>
|
</NCard>
|
||||||
</NGridItem>
|
</NGridItem>
|
||||||
<NGridItem>
|
<NGridItem>
|
||||||
<NCard>
|
<NCard>
|
||||||
<NStatistic label="结余" :value="Number(summary.balance).toFixed(2)" />
|
<NStatistic :label="t('dashboard.balance')" :value="Number(summary.balance).toFixed(2)" />
|
||||||
</NCard>
|
</NCard>
|
||||||
</NGridItem>
|
</NGridItem>
|
||||||
<NGridItem>
|
<NGridItem>
|
||||||
<NCard>
|
<NCard>
|
||||||
<NStatistic label="交易笔数" :value="summary.transactionCount" />
|
<NStatistic :label="t('dashboard.transactionCount')" :value="summary.transactionCount" />
|
||||||
</NCard>
|
</NCard>
|
||||||
</NGridItem>
|
</NGridItem>
|
||||||
</NGrid>
|
</NGrid>
|
||||||
@@ -451,17 +460,17 @@ onMounted(async () => {
|
|||||||
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
|
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
|
||||||
<NGridItem>
|
<NGridItem>
|
||||||
<NCard>
|
<NCard>
|
||||||
<NStatistic label="日均收入" :value="Number(summary.avgDailyIncome).toFixed(2)" />
|
<NStatistic :label="t('dashboard.avgDailyIncome')" :value="Number(summary.avgDailyIncome).toFixed(2)" />
|
||||||
</NCard>
|
</NCard>
|
||||||
</NGridItem>
|
</NGridItem>
|
||||||
<NGridItem>
|
<NGridItem>
|
||||||
<NCard>
|
<NCard>
|
||||||
<NStatistic label="日均支出" :value="Number(summary.avgDailyExpense).toFixed(2)" />
|
<NStatistic :label="t('dashboard.avgDailyExpense')" :value="Number(summary.avgDailyExpense).toFixed(2)" />
|
||||||
</NCard>
|
</NCard>
|
||||||
</NGridItem>
|
</NGridItem>
|
||||||
<NGridItem>
|
<NGridItem>
|
||||||
<NCard>
|
<NCard>
|
||||||
<NStatistic label="最大单笔支出" :value="Number(summary.maxExpenseAmount).toFixed(2)">
|
<NStatistic :label="t('dashboard.maxExpense')" :value="Number(summary.maxExpenseAmount).toFixed(2)">
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<span style="font-size: 12px; color: #999;">{{ (summary.maxExpenseRemark || '').slice(0, 12) }}</span>
|
<span style="font-size: 12px; color: #999;">{{ (summary.maxExpenseRemark || '').slice(0, 12) }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -470,7 +479,7 @@ onMounted(async () => {
|
|||||||
</NGridItem>
|
</NGridItem>
|
||||||
<NGridItem>
|
<NGridItem>
|
||||||
<NCard>
|
<NCard>
|
||||||
<NStatistic label="平均每笔支出" :value="Number(summary.avgExpenseAmount).toFixed(2)" />
|
<NStatistic :label="t('dashboard.avgExpense')" :value="Number(summary.avgExpenseAmount).toFixed(2)" />
|
||||||
</NCard>
|
</NCard>
|
||||||
</NGridItem>
|
</NGridItem>
|
||||||
</NGrid>
|
</NGrid>
|
||||||
@@ -479,7 +488,7 @@ onMounted(async () => {
|
|||||||
<NCard style="margin-bottom: 16px;">
|
<NCard style="margin-bottom: 16px;">
|
||||||
<template #header>
|
<template #header>
|
||||||
<NSpace justify="space-between" align="center">
|
<NSpace justify="space-between" align="center">
|
||||||
<span>收支趋势</span>
|
<span>{{ t('dashboard.trend') }}</span>
|
||||||
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" />
|
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" />
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
// 主布局组件:包含左侧导航菜单、顶部用户信息栏和内容区域
|
// 主布局组件:包含左侧导航菜单、顶部用户信息栏和内容区域
|
||||||
import { h, computed } from 'vue'
|
import { h, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NButton, NSpace, NIcon } from 'naive-ui'
|
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NButton, NSpace, NIcon } from 'naive-ui'
|
||||||
import type { MenuOption } from 'naive-ui'
|
import type { MenuOption } from 'naive-ui'
|
||||||
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, LogOutOutline } from '@vicons/ionicons5'
|
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, LogOutOutline } from '@vicons/ionicons5'
|
||||||
import { useUserStore } from '../store/user'
|
import { useUserStore } from '../store/user'
|
||||||
|
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 根据当前路由路径计算菜单激活项
|
// 根据当前路由路径计算菜单激活项
|
||||||
const activeKey = computed(() => {
|
const activeKey = computed(() => {
|
||||||
@@ -27,13 +30,13 @@ function renderIcon(icon: any) {
|
|||||||
return () => h(NIcon, null, { default: () => h(icon) })
|
return () => h(NIcon, null, { default: () => h(icon) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 侧边栏菜单配置项
|
// 侧边栏菜单配置项(随语言切换响应式更新)
|
||||||
const menuOptions: MenuOption[] = [
|
const menuOptions = computed<MenuOption[]>(() => [
|
||||||
{ label: '统计面板', key: 'dashboard', icon: renderIcon(BarChartOutline) },
|
{ label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) },
|
||||||
{ label: '收支记录', key: 'transactions', icon: renderIcon(ListOutline) },
|
{ label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) },
|
||||||
{ label: '分类管理', key: 'categories', icon: renderIcon(FolderOpenOutline) },
|
{ label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) },
|
||||||
{ label: '标签管理', key: 'tags', icon: renderIcon(PricetagsOutline) },
|
{ label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) },
|
||||||
]
|
])
|
||||||
|
|
||||||
/** 菜单点击事件:根据 key 导航到对应路由 */
|
/** 菜单点击事件:根据 key 导航到对应路由 */
|
||||||
function handleMenuUpdate(key: string) {
|
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;">
|
<div style="text-align: center; padding: 16px 0; font-size: 18px; font-weight: bold; color: #333;">
|
||||||
收支管理
|
{{ t('layout.appTitle') }}
|
||||||
</div>
|
</div>
|
||||||
<!-- 导航菜单 -->
|
<!-- 导航菜单 -->
|
||||||
<NMenu
|
<NMenu
|
||||||
@@ -69,13 +72,14 @@ function handleLogout() {
|
|||||||
<NLayout>
|
<NLayout>
|
||||||
<!-- 顶部栏:显示用户名和退出按钮 -->
|
<!-- 顶部栏:显示用户名和退出按钮 -->
|
||||||
<NLayoutHeader bordered style="padding: 12px 24px; display: flex; justify-content: space-between; align-items: center;">
|
<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>
|
<span style="font-size: 16px; font-weight: 500;">{{ userStore.nickname || t('layout.user') }}</span>
|
||||||
<NSpace>
|
<NSpace align="center">
|
||||||
|
<LocaleSwitch />
|
||||||
<NButton quaternary size="small" @click="handleLogout">
|
<NButton quaternary size="small" @click="handleLogout">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<NIcon :component="LogOutOutline" />
|
<NIcon :component="LogOutOutline" />
|
||||||
</template>
|
</template>
|
||||||
退出
|
{{ t('layout.logout') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NLayoutHeader>
|
</NLayoutHeader>
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
// 登录页面组件:提供用户名/密码表单,验证后跳转至首页
|
// 登录页面组件:提供用户名/密码表单,验证后跳转至首页
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
|
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
|
||||||
import { login } from '../api/user'
|
import { login } from '../api/user'
|
||||||
import { useUserStore } from '../store/user'
|
import { useUserStore } from '../store/user'
|
||||||
|
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage() // 消息提示工具
|
const message = useMessage() // 消息提示工具
|
||||||
const userStore = useUserStore() // 用户状态管理
|
const userStore = useUserStore() // 用户状态管理
|
||||||
|
const { t } = useI18n() // 国际化
|
||||||
|
|
||||||
// 登录表单数据
|
// 登录表单数据
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -20,7 +23,7 @@ const loading = ref(false) // 登录按钮加载状态
|
|||||||
/** 处理登录操作:校验表单 -> 调用登录接口 -> 保存 token -> 跳转首页 */
|
/** 处理登录操作:校验表单 -> 调用登录接口 -> 保存 token -> 跳转首页 */
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
if (!form.value.username || !form.value.password) {
|
if (!form.value.username || !form.value.password) {
|
||||||
message.warning('请填写用户名和密码')
|
message.warning(t('login.fillRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -28,10 +31,10 @@ async function handleLogin() {
|
|||||||
const res: any = await login(form.value)
|
const res: any = await login(form.value)
|
||||||
userStore.setToken(res.data.token) // 保存 token
|
userStore.setToken(res.data.token) // 保存 token
|
||||||
userStore.setUserInfo(res.data.user.nickname, res.data.user.id) // 保存用户信息
|
userStore.setUserInfo(res.data.user.nickname, res.data.user.id) // 保存用户信息
|
||||||
message.success('登录成功')
|
message.success(t('login.success'))
|
||||||
router.push('/dashboard') // 跳转到统计面板
|
router.push('/dashboard') // 跳转到统计面板
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '登录失败')
|
message.error(e.message || t('login.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -40,17 +43,20 @@ async function handleLogin() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="login-container">
|
<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>
|
<NForm>
|
||||||
<NFormItem label="用户名">
|
<NFormItem :label="t('login.username')">
|
||||||
<NInput v-model:value="form.username" placeholder="请输入用户名" @keyup.enter="handleLogin" />
|
<NInput v-model:value="form.username" :placeholder="t('login.usernamePlaceholder')" @keyup.enter="handleLogin" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="密码">
|
<NFormItem :label="t('login.password')">
|
||||||
<NInput v-model:value="form.password" type="password" show-password-on="click" placeholder="请输入密码" @keyup.enter="handleLogin" />
|
<NInput v-model:value="form.password" type="password" show-password-on="click" :placeholder="t('login.passwordPlaceholder')" @keyup.enter="handleLogin" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NSpace vertical>
|
<NSpace vertical>
|
||||||
<NButton type="primary" block :loading="loading" @click="handleLogin">登录</NButton>
|
<NButton type="primary" block :loading="loading" @click="handleLogin">{{ t('login.submit') }}</NButton>
|
||||||
<NButton text type="info" @click="router.push('/register')">没有账号?去注册</NButton>
|
<NButton text type="info" @click="router.push('/register')">{{ t('login.toRegister') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NForm>
|
</NForm>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
// 注册页面组件:提供注册表单(用户名、昵称、密码、确认密码),校验后调用注册接口
|
// 注册页面组件:提供注册表单(用户名、昵称、密码、确认密码),校验后调用注册接口
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
|
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
|
||||||
import { register } from '../api/user'
|
import { register } from '../api/user'
|
||||||
|
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage() // 消息提示工具
|
const message = useMessage() // 消息提示工具
|
||||||
|
const { t } = useI18n() // 国际化
|
||||||
|
|
||||||
// 注册表单数据
|
// 注册表单数据
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -21,20 +24,20 @@ const loading = ref(false) // 注册按钮加载状态
|
|||||||
async function handleRegister() {
|
async function handleRegister() {
|
||||||
// 校验必填字段
|
// 校验必填字段
|
||||||
if (!form.value.username || !form.value.password) {
|
if (!form.value.username || !form.value.password) {
|
||||||
message.warning('请填写用户名和密码')
|
message.warning(t('register.fillRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (form.value.username.length < 3) {
|
if (form.value.username.length < 3) {
|
||||||
message.warning('用户名至少3个字符')
|
message.warning(t('register.usernameMin'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (form.value.password.length < 6) {
|
if (form.value.password.length < 6) {
|
||||||
message.warning('密码至少6个字符')
|
message.warning(t('register.passwordMin'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 校验两次密码是否一致
|
// 校验两次密码是否一致
|
||||||
if (form.value.password !== form.value.confirmPassword) {
|
if (form.value.password !== form.value.confirmPassword) {
|
||||||
message.warning('两次密码不一致')
|
message.warning(t('register.passwordMismatch'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -44,10 +47,10 @@ async function handleRegister() {
|
|||||||
password: form.value.password,
|
password: form.value.password,
|
||||||
nickname: form.value.nickname || undefined, // 昵称为空时传 undefined
|
nickname: form.value.nickname || undefined, // 昵称为空时传 undefined
|
||||||
})
|
})
|
||||||
message.success('注册成功,请登录')
|
message.success(t('register.success'))
|
||||||
router.push('/login') // 跳转到登录页
|
router.push('/login') // 跳转到登录页
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '注册失败')
|
message.error(e.message || t('register.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -56,23 +59,26 @@ async function handleRegister() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="login-container">
|
<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>
|
<NForm>
|
||||||
<NFormItem label="用户名">
|
<NFormItem :label="t('register.username')">
|
||||||
<NInput v-model:value="form.username" placeholder="3-50个字符" />
|
<NInput v-model:value="form.username" :placeholder="t('register.usernamePlaceholder')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="昵称">
|
<NFormItem :label="t('register.nickname')">
|
||||||
<NInput v-model:value="form.nickname" placeholder="可选" />
|
<NInput v-model:value="form.nickname" :placeholder="t('register.nicknamePlaceholder')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="密码">
|
<NFormItem :label="t('register.password')">
|
||||||
<NInput v-model:value="form.password" type="password" show-password-on="click" placeholder="至少6个字符" />
|
<NInput v-model:value="form.password" type="password" show-password-on="click" :placeholder="t('register.passwordPlaceholder')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="确认密码">
|
<NFormItem :label="t('register.confirmPassword')">
|
||||||
<NInput v-model:value="form.confirmPassword" type="password" show-password-on="click" placeholder="再次输入密码" />
|
<NInput v-model:value="form.confirmPassword" type="password" show-password-on="click" :placeholder="t('register.confirmPasswordPlaceholder')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NSpace vertical>
|
<NSpace vertical>
|
||||||
<NButton type="primary" block :loading="loading" @click="handleRegister">注册</NButton>
|
<NButton type="primary" block :loading="loading" @click="handleRegister">{{ t('register.submit') }}</NButton>
|
||||||
<NButton text type="info" @click="router.push('/login')">已有账号?去登录</NButton>
|
<NButton text type="info" @click="router.push('/login')">{{ t('register.toLogin') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NForm>
|
</NForm>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<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 { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NColorPicker, NTag } from 'naive-ui'
|
||||||
import type { DataTableColumns } from 'naive-ui'
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
import { getTagList, createTag, updateTag, deleteTag } from '../api/tag'
|
import { getTagList, createTag, updateTag, deleteTag } from '../api/tag'
|
||||||
|
|
||||||
const message = useMessage() // 消息提示
|
const message = useMessage() // 消息提示
|
||||||
const dialog = useDialog() // 对话框
|
const dialog = useDialog() // 对话框
|
||||||
|
const { t } = useI18n() // 国际化
|
||||||
|
|
||||||
/** 标签数据结构 */
|
/** 标签数据结构 */
|
||||||
interface TagItem {
|
interface TagItem {
|
||||||
@@ -28,20 +30,20 @@ const form = ref<{ name: string; color: string; sortOrder: number }>({ name: '',
|
|||||||
function createColumns(): DataTableColumns<TagItem> {
|
function createColumns(): DataTableColumns<TagItem> {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: '标签', key: 'name',
|
title: t('tag.label'), key: 'name',
|
||||||
render(row) {
|
render(row) {
|
||||||
return h(NTag, { color: { color: row.color || '#2080f0', textColor: '#fff' } }, { default: () => row.name })
|
return h(NTag, { color: { color: row.color || '#2080f0', textColor: '#fff' } }, { default: () => row.name })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '颜色', key: 'color', width: 140 },
|
{ title: t('tag.color'), key: 'color', width: 140 },
|
||||||
{ title: '排序', key: 'sortOrder', width: 80 },
|
{ title: t('common.sort'), key: 'sortOrder', width: 80 },
|
||||||
{
|
{
|
||||||
title: '操作', key: 'actions', width: 180,
|
title: t('common.operation'), key: 'actions', width: 180,
|
||||||
render(row) {
|
render(row) {
|
||||||
return h(NSpace, null, {
|
return h(NSpace, null, {
|
||||||
default: () => [
|
default: () => [
|
||||||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
|
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
|
||||||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
|
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() {
|
async function fetchData() {
|
||||||
@@ -58,7 +60,7 @@ async function fetchData() {
|
|||||||
const res: any = await getTagList()
|
const res: any = await getTagList()
|
||||||
tags.value = res.data
|
tags.value = res.data
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '加载失败')
|
message.error(e.message || t('common.loadFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -81,38 +83,38 @@ function handleEdit(row: TagItem) {
|
|||||||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!form.value.name) {
|
if (!form.value.name) {
|
||||||
message.warning('请输入标签名称')
|
message.warning(t('tag.nameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await updateTag(editingId.value, form.value)
|
await updateTag(editingId.value, form.value)
|
||||||
message.success('修改成功')
|
message.success(t('common.updateSuccess'))
|
||||||
} else {
|
} else {
|
||||||
await createTag(form.value)
|
await createTag(form.value)
|
||||||
message.success('创建成功')
|
message.success(t('common.createSuccess'))
|
||||||
}
|
}
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
await fetchData()
|
await fetchData()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '操作失败')
|
message.error(e.message || t('common.operationFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除标签:弹出确认对话框,确认后执行删除并刷新列表 */
|
/** 删除标签:弹出确认对话框,确认后执行删除并刷新列表 */
|
||||||
function handleDelete(id: number) {
|
function handleDelete(id: number) {
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
title: '确认删除',
|
title: t('common.confirmDelete'),
|
||||||
content: '删除后将从所有交易记录中移除该标签,确定删除吗?',
|
content: t('tag.deleteConfirm'),
|
||||||
positiveText: '删除',
|
positiveText: t('common.delete'),
|
||||||
negativeText: '取消',
|
negativeText: t('common.cancel'),
|
||||||
onPositiveClick: async () => {
|
onPositiveClick: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteTag(id)
|
await deleteTag(id)
|
||||||
message.success('删除成功')
|
message.success(t('common.deleteSuccess'))
|
||||||
await fetchData()
|
await fetchData()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '删除失败')
|
message.error(e.message || t('common.deleteFailed'))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -126,26 +128,26 @@ onMounted(fetchData)
|
|||||||
<div>
|
<div>
|
||||||
<!-- 顶部:标题和新增按钮 -->
|
<!-- 顶部:标题和新增按钮 -->
|
||||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||||
<h2 style="margin: 0;">标签管理</h2>
|
<h2 style="margin: 0;">{{ t('tag.title') }}</h2>
|
||||||
<NButton type="primary" @click="handleAdd">新增标签</NButton>
|
<NButton type="primary" @click="handleAdd">{{ t('tag.add') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
<!-- 标签数据表格 -->
|
<!-- 标签数据表格 -->
|
||||||
<NDataTable :columns="columns" :data="tags" :loading="loading" :bordered="false" />
|
<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>
|
<NForm>
|
||||||
<NFormItem label="标签名称">
|
<NFormItem :label="t('tag.name')">
|
||||||
<NInput v-model:value="form.name" placeholder="请输入标签名称" />
|
<NInput v-model:value="form.name" :placeholder="t('tag.namePlaceholder')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="颜色">
|
<NFormItem :label="t('tag.color')">
|
||||||
<NColorPicker v-model:value="form.color" />
|
<NColorPicker v-model:value="form.color" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="排序">
|
<NFormItem :label="t('tag.sort')">
|
||||||
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NButton type="primary" block @click="handleSubmit">确定</NButton>
|
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
||||||
</NForm>
|
</NForm>
|
||||||
</NModal>
|
</NModal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
|
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
|
||||||
import { ref, onMounted, computed, h, watch } from 'vue'
|
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 { 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 { EyeOutline, EyeOffOutline } from '@vicons/ionicons5'
|
||||||
import type { DataTableColumns } from 'naive-ui'
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
@@ -11,6 +12,7 @@ import BillImportModal from './BillImportModal.vue'
|
|||||||
|
|
||||||
const message = useMessage() // 消息提示
|
const message = useMessage() // 消息提示
|
||||||
const dialog = useDialog() // 对话框
|
const dialog = useDialog() // 对话框
|
||||||
|
const { t, te } = useI18n() // 国际化
|
||||||
|
|
||||||
/** 交易记录数据结构 */
|
/** 交易记录数据结构 */
|
||||||
interface TransactionItem {
|
interface TransactionItem {
|
||||||
@@ -51,12 +53,12 @@ const total = ref(0) // 总记录数
|
|||||||
const page = ref(1) // 当前页码
|
const page = ref(1) // 当前页码
|
||||||
const pageSize = ref(20) // 每页条数
|
const pageSize = ref(20) // 每页条数
|
||||||
const sortValue = ref('transactionTime-desc') // 排序方式:排序字段-方向
|
const sortValue = ref('transactionTime-desc') // 排序方式:排序字段-方向
|
||||||
const sortOptions = [
|
const sortOptions = computed(() => [
|
||||||
{ label: '交易时间(新→旧)', value: 'transactionTime-desc' },
|
{ label: t('transaction.sort.timeDesc'), value: 'transactionTime-desc' },
|
||||||
{ label: '交易时间(旧→新)', value: 'transactionTime-asc' },
|
{ label: t('transaction.sort.timeAsc'), value: 'transactionTime-asc' },
|
||||||
{ label: '金额(高→低)', value: 'amount-desc' },
|
{ label: t('transaction.sort.amountDesc'), value: 'amount-desc' },
|
||||||
{ label: '金额(低→高)', value: 'amount-asc' },
|
{ label: t('transaction.sort.amountAsc'), value: 'amount-asc' },
|
||||||
]
|
])
|
||||||
const sortField = computed(() => sortValue.value.split('-')[0]) // 服务端排序字段
|
const sortField = computed(() => sortValue.value.split('-')[0]) // 服务端排序字段
|
||||||
const sortOrder = computed(() => sortValue.value.split('-')[1]) // 服务端排序方向:asc/desc
|
const sortOrder = computed(() => sortValue.value.split('-')[1]) // 服务端排序方向:asc/desc
|
||||||
const pageSizes = [10, 20, 50, 100, 200, 500] // 可选的每页条数
|
const pageSizes = [10, 20, 50, 100, 200, 500] // 可选的每页条数
|
||||||
@@ -86,11 +88,11 @@ const batchRemoveTagIds = ref<number[]>([]) // 批量移除的标签
|
|||||||
const batching = ref(false) // 批量设置提交状态
|
const batching = ref(false) // 批量设置提交状态
|
||||||
|
|
||||||
// 批量设置计入统计选项
|
// 批量设置计入统计选项
|
||||||
const batchStatsOptions = [
|
const batchStatsOptions = computed(() => [
|
||||||
{ label: '不修改', value: 'null' },
|
{ label: t('transaction.batchStats.keep'), value: 'null' },
|
||||||
{ label: '计入统计', value: 'true' },
|
{ label: t('transaction.batchStats.include'), value: 'true' },
|
||||||
{ label: '不计入统计', value: 'false' },
|
{ label: t('transaction.batchStats.exclude'), value: 'false' },
|
||||||
]
|
])
|
||||||
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
|
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
|
||||||
const categories = ref<CategoryOption[]>([]) // 分类选项列表
|
const categories = ref<CategoryOption[]>([]) // 分类选项列表
|
||||||
const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量)
|
const allTags = ref<TagOption[]>([]) // 全部标签(用于筛选、编辑、批量)
|
||||||
@@ -109,18 +111,18 @@ const form = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 类型选项
|
// 类型选项
|
||||||
const typeOptions = [
|
const typeOptions = computed(() => [
|
||||||
{ label: '支出', value: 'EXPENSE' },
|
{ label: t('common.types.expense'), value: 'EXPENSE' },
|
||||||
{ label: '收入', value: 'INCOME' },
|
{ label: t('common.types.income'), value: 'INCOME' },
|
||||||
]
|
])
|
||||||
|
|
||||||
// 支付方式选项
|
// 支付方式选项
|
||||||
const paymentOptions = [
|
const paymentOptions = computed(() => [
|
||||||
{ label: '微信', value: 'WECHAT' },
|
{ label: t('common.payment.wechat'), value: 'WECHAT' },
|
||||||
{ label: '支付宝', value: 'ALIPAY' },
|
{ label: t('common.payment.alipay'), value: 'ALIPAY' },
|
||||||
{ label: '现金', value: 'CASH' },
|
{ label: t('common.payment.cash'), value: 'CASH' },
|
||||||
{ label: '银行卡', value: 'BANK' },
|
{ label: t('common.payment.bank'), value: 'BANK' },
|
||||||
]
|
])
|
||||||
|
|
||||||
// 筛选条件状态
|
// 筛选条件状态
|
||||||
const filterType = ref<string[]>([]) // 类型筛选(多选)
|
const filterType = ref<string[]>([]) // 类型筛选(多选)
|
||||||
@@ -155,7 +157,7 @@ function clearMoreFilters() {
|
|||||||
|
|
||||||
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
|
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
|
||||||
const categoryFilterOptions = computed(() => [
|
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)),
|
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -172,10 +174,10 @@ function buildCategoryFilter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 计入统计筛选下拉选项
|
// 计入统计筛选下拉选项
|
||||||
const includeInStatsOptions = [
|
const includeInStatsOptions = computed(() => [
|
||||||
{ label: '计入', value: 'true' },
|
{ label: t('transaction.includeFilter.yes'), value: 'true' },
|
||||||
{ label: '不计入', value: 'false' },
|
{ label: t('transaction.includeFilter.no'), value: 'false' },
|
||||||
]
|
])
|
||||||
|
|
||||||
/** 筛选条件偏好存储键 */
|
/** 筛选条件偏好存储键 */
|
||||||
const FILTER_PREFS_KEY = 'finance.transaction.filterPrefs'
|
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.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
||||||
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
|
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.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
|
if (Array.isArray(p.filterTag)) filterTag.value = p.filterTag
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略损坏的偏好数据
|
// 忽略损坏的偏好数据
|
||||||
@@ -239,10 +241,11 @@ function formatTimestamp(ts: number, isEnd = false): string {
|
|||||||
return `${date}T${time}`
|
return `${date}T${time}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将支付方式编码转换为中文标签 */
|
/** 将支付方式编码转换为当前语言的标签 */
|
||||||
function getPaymentLabel(val: string) {
|
function getPaymentLabel(val: string) {
|
||||||
const map: Record<string, string> = { WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', BANK: '银行卡' }
|
if (!val) return '-'
|
||||||
return map[val] || val || '-'
|
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)
|
await toggleRemarkHidden(row.id, hidden)
|
||||||
row.remarkHidden = hidden
|
row.remarkHidden = hidden
|
||||||
concealRemark(row.id)
|
concealRemark(row.id)
|
||||||
message.success(hidden ? '已隐藏备注' : '已取消隐藏')
|
message.success(hidden ? t('transaction.remarkHiddenSuccess') : t('transaction.remarkShownSuccess'))
|
||||||
} catch (e: any) {
|
} 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> {
|
function createColumns(): DataTableColumns<TransactionItem> {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: '交易时间', key: 'transactionTime', width: 170,
|
title: t('transaction.column.transactionTime'), key: 'transactionTime', width: 170,
|
||||||
render(row) {
|
render(row) {
|
||||||
const time = row.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
|
const time = row.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
|
||||||
const created = row.createdAt?.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) {
|
render(row) {
|
||||||
if (!row.includeInStats) {
|
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) {
|
render(row) {
|
||||||
const name = row.categoryName || '未分类'
|
const name = row.categoryName || t('transaction.uncategorized')
|
||||||
return row.categoryIcon ? `${row.categoryIcon} ${name}` : name
|
return row.categoryIcon ? `${row.categoryIcon} ${name}` : name
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '金额', key: 'amount', width: 120,
|
title: t('transaction.column.amount'), key: 'amount', width: 120,
|
||||||
render(row) {
|
render(row) {
|
||||||
if (!row.includeInStats) {
|
if (!row.includeInStats) {
|
||||||
return h('span', { style: { color: '#999', fontWeight: 'bold' } }, `${row.amount.toFixed(2)}`)
|
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) },
|
render(row) { return getPaymentLabel(row.paymentMethod) },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '备注', key: 'remark', minWidth: 220,
|
title: t('transaction.column.remark'), key: 'remark', minWidth: 220,
|
||||||
render(row) {
|
render(row) {
|
||||||
const hidden = !!row.remarkHidden
|
const hidden = !!row.remarkHidden
|
||||||
const collapsed = hidden && !revealedRemarkIds.value.includes(row.id)
|
const collapsed = hidden && !revealedRemarkIds.value.includes(row.id)
|
||||||
const nodes: any[] = [
|
const nodes: any[] = [
|
||||||
h('span', {
|
h('span', {
|
||||||
class: 'remark-text',
|
class: 'remark-text',
|
||||||
title: collapsed ? '备注已隐藏' : (row.remark || ''),
|
title: collapsed ? t('transaction.remarkHidden') : (row.remark || ''),
|
||||||
}, collapsed ? '******' : (row.remark || '-')),
|
}, collapsed ? '******' : (row.remark || '-')),
|
||||||
]
|
]
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
nodes.push(renderRemarkAction('查看', EyeOutline, () => revealRemark(row.id)))
|
nodes.push(renderRemarkAction(t('transaction.remarkView'), EyeOutline, () => revealRemark(row.id)))
|
||||||
nodes.push(renderRemarkAction('取消隐藏', EyeOffOutline, () => setRemarkHidden(row, false)))
|
nodes.push(renderRemarkAction(t('transaction.remarkUnhide'), EyeOffOutline, () => setRemarkHidden(row, false)))
|
||||||
} else if (hidden) {
|
} else if (hidden) {
|
||||||
nodes.push(renderRemarkAction('收起', EyeOffOutline, () => concealRemark(row.id)))
|
nodes.push(renderRemarkAction(t('transaction.remarkCollapse'), EyeOffOutline, () => concealRemark(row.id)))
|
||||||
nodes.push(renderRemarkAction('取消隐藏', EyeOutline, () => setRemarkHidden(row, false)))
|
nodes.push(renderRemarkAction(t('transaction.remarkUnhide'), EyeOutline, () => setRemarkHidden(row, false)))
|
||||||
} else {
|
} 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)
|
return h('div', { class: 'remark-cell' }, nodes)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '标签', key: 'tags', minWidth: 140,
|
title: t('transaction.column.tags'), key: 'tags', minWidth: 140,
|
||||||
render(row) {
|
render(row) {
|
||||||
if (!row.tags || row.tags.length === 0) return '-'
|
if (!row.tags || row.tags.length === 0) return '-'
|
||||||
return h(NSpace, { size: 4 }, {
|
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) {
|
render(row) {
|
||||||
return h(NSwitch, {
|
return h(NSwitch, {
|
||||||
value: row.includeInStats,
|
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) {
|
render(row) {
|
||||||
return h(NSpace, null, {
|
return h(NSpace, null, {
|
||||||
default: () => [
|
default: () => [
|
||||||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
|
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
|
||||||
h(NButton, { size: 'small', disabled: !row.rawData, onClick: () => handleViewRaw(row) }, { default: () => '原始数据' }),
|
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: () => '删除' }),
|
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() {
|
async function fetchCategories() {
|
||||||
@@ -442,7 +445,7 @@ async function fetchData() {
|
|||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
revealedRemarkIds.value = []
|
revealedRemarkIds.value = []
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '加载失败')
|
message.error(e.message || t('common.loadFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -457,7 +460,7 @@ watch(sortValue, () => {
|
|||||||
/** 查看某条记录的原始账单数据(格式化为缩进 JSON) */
|
/** 查看某条记录的原始账单数据(格式化为缩进 JSON) */
|
||||||
function handleViewRaw(row: TransactionItem) {
|
function handleViewRaw(row: TransactionItem) {
|
||||||
if (!row.rawData) {
|
if (!row.rawData) {
|
||||||
message.warning('该记录没有原始账单数据')
|
message.warning(t('transaction.noRawData'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -494,11 +497,11 @@ function handleEdit(row: TransactionItem) {
|
|||||||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!form.value.categoryId) {
|
if (!form.value.categoryId) {
|
||||||
message.warning('请选择分类')
|
message.warning(t('transaction.selectCategory'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!form.value.amount || form.value.amount <= 0) {
|
if (!form.value.amount || form.value.amount <= 0) {
|
||||||
message.warning('请输入有效金额')
|
message.warning(t('transaction.validAmount'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
@@ -514,32 +517,32 @@ async function handleSubmit() {
|
|||||||
try {
|
try {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await updateTransaction(editingId.value, data)
|
await updateTransaction(editingId.value, data)
|
||||||
message.success('修改成功')
|
message.success(t('common.updateSuccess'))
|
||||||
} else {
|
} else {
|
||||||
await createTransaction(data)
|
await createTransaction(data)
|
||||||
message.success('创建成功')
|
message.success(t('common.createSuccess'))
|
||||||
}
|
}
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
await fetchData()
|
await fetchData()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '操作失败')
|
message.error(e.message || t('common.operationFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除记录:弹出确认对话框,确认后执行删除并刷新列表 */
|
/** 删除记录:弹出确认对话框,确认后执行删除并刷新列表 */
|
||||||
function handleDelete(id: number) {
|
function handleDelete(id: number) {
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
title: '确认删除',
|
title: t('common.confirmDelete'),
|
||||||
content: '确定要删除这条记录吗?',
|
content: t('transaction.deleteConfirm'),
|
||||||
positiveText: '删除',
|
positiveText: t('common.delete'),
|
||||||
negativeText: '取消',
|
negativeText: t('common.cancel'),
|
||||||
onPositiveClick: async () => {
|
onPositiveClick: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteTransaction(id)
|
await deleteTransaction(id)
|
||||||
message.success('删除成功')
|
message.success(t('common.deleteSuccess'))
|
||||||
await fetchData()
|
await fetchData()
|
||||||
} catch (e: any) {
|
} 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 {
|
try {
|
||||||
await toggleTransactionStats(row.id, value)
|
await toggleTransactionStats(row.id, value)
|
||||||
row.includeInStats = value
|
row.includeInStats = value
|
||||||
message.success(value ? '已计入统计' : '已排除统计')
|
message.success(value ? t('transaction.statsIncluded') : t('transaction.statsExcluded'))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '操作失败')
|
message.error(e.message || t('common.operationFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 打开批量设置分类弹窗 */
|
/** 打开批量设置分类弹窗 */
|
||||||
function handleBatchOpen() {
|
function handleBatchOpen() {
|
||||||
if (transactions.value.length === 0) {
|
if (transactions.value.length === 0) {
|
||||||
message.warning('当前表格没有可设置的记录')
|
message.warning(t('transaction.noRecordsToSet'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
batchExpenseCategoryId.value = null
|
batchExpenseCategoryId.value = null
|
||||||
@@ -609,7 +612,7 @@ async function handleBatchSubmit() {
|
|||||||
const hasCategoryOrStats = !!(batchExpenseCategoryId.value || batchIncomeCategoryId.value || batchIncludeInStats.value !== 'null')
|
const hasCategoryOrStats = !!(batchExpenseCategoryId.value || batchIncomeCategoryId.value || batchIncludeInStats.value !== 'null')
|
||||||
const hasTags = batchAddTagIds.value.length > 0 || batchRemoveTagIds.value.length > 0
|
const hasTags = batchAddTagIds.value.length > 0 || batchRemoveTagIds.value.length > 0
|
||||||
if (!hasCategoryOrStats && !hasTags) {
|
if (!hasCategoryOrStats && !hasTags) {
|
||||||
message.warning('请至少选择分类、计入统计或标签')
|
message.warning(t('transaction.batchNothingSelected'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
batching.value = true
|
batching.value = true
|
||||||
@@ -630,11 +633,11 @@ async function handleBatchSubmit() {
|
|||||||
removeTagIds: batchRemoveTagIds.value,
|
removeTagIds: batchRemoveTagIds.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
message.success(`已更新 ${ids.length} 条记录`)
|
message.success(t('transaction.batchUpdated', { count: ids.length }))
|
||||||
showBatchModal.value = false
|
showBatchModal.value = false
|
||||||
await fetchData()
|
await fetchData()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '操作失败')
|
message.error(e.message || t('common.operationFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
batching.value = false
|
batching.value = false
|
||||||
}
|
}
|
||||||
@@ -661,14 +664,14 @@ async function handleExport() {
|
|||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
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)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
message.success('导出成功')
|
message.success(t('transaction.exportSuccess'))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || '导出失败')
|
message.error(e.message || t('transaction.exportFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,14 +688,14 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<!-- 顶部:标题和新增按钮 -->
|
<!-- 顶部:标题和新增按钮 -->
|
||||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||||
<h2 style="margin: 0;">收支记录</h2>
|
<h2 style="margin: 0;">{{ t('transaction.title') }}</h2>
|
||||||
<NSpace align="center">
|
<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;" />
|
<NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" />
|
||||||
<NButton @click="handleBatchOpen">一键设置</NButton>
|
<NButton @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton>
|
||||||
<NButton @click="showImportModal = true">导入账单</NButton>
|
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
|
||||||
<NButton @click="handleExport">导出 Excel</NButton>
|
<NButton @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
|
||||||
<NButton type="primary" @click="handleAdd">新增记录</NButton>
|
<NButton type="primary" @click="handleAdd">{{ t('transaction.addRecord') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
@@ -705,7 +708,7 @@ onMounted(() => {
|
|||||||
multiple
|
multiple
|
||||||
clearable
|
clearable
|
||||||
:max-tag-count="2"
|
:max-tag-count="2"
|
||||||
placeholder="类型"
|
:placeholder="t('transaction.column.type')"
|
||||||
style="width: 160px;"
|
style="width: 160px;"
|
||||||
/>
|
/>
|
||||||
<NSelect
|
<NSelect
|
||||||
@@ -717,7 +720,7 @@ onMounted(() => {
|
|||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
:max-tag-count="2"
|
:max-tag-count="2"
|
||||||
placeholder="分类"
|
:placeholder="t('transaction.column.category')"
|
||||||
style="width: 200px;"
|
style="width: 200px;"
|
||||||
/>
|
/>
|
||||||
<NDatePicker
|
<NDatePicker
|
||||||
@@ -729,14 +732,14 @@ onMounted(() => {
|
|||||||
<NInput
|
<NInput
|
||||||
v-model:value="filterRemark"
|
v-model:value="filterRemark"
|
||||||
clearable
|
clearable
|
||||||
placeholder="备注搜索"
|
:placeholder="t('transaction.column.remark')"
|
||||||
style="width: 180px;"
|
style="width: 180px;"
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
|
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<NButton>
|
<NButton>
|
||||||
更多筛选
|
{{ t('transaction.moreFilter') }}
|
||||||
<NTag v-if="moreFilterCount > 0" :bordered="false" size="small" type="primary" style="margin-left: 6px;">
|
<NTag v-if="moreFilterCount > 0" :bordered="false" size="small" type="primary" style="margin-left: 6px;">
|
||||||
{{ moreFilterCount }}
|
{{ moreFilterCount }}
|
||||||
</NTag>
|
</NTag>
|
||||||
@@ -744,19 +747,19 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
<NSpace vertical :size="12" style="width: 260px;">
|
<NSpace vertical :size="12" style="width: 260px;">
|
||||||
<div>
|
<div>
|
||||||
<div class="filter-label">支付方式</div>
|
<div class="filter-label">{{ t('transaction.column.paymentMethod') }}</div>
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="filterPaymentMethod"
|
v-model:value="filterPaymentMethod"
|
||||||
:options="paymentOptions"
|
:options="paymentOptions"
|
||||||
multiple
|
multiple
|
||||||
clearable
|
clearable
|
||||||
:max-tag-count="2"
|
:max-tag-count="2"
|
||||||
placeholder="全部"
|
:placeholder="t('common.all')"
|
||||||
style="width: 100%;"
|
style="width: 100%;"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="filter-label">标签</div>
|
<div class="filter-label">{{ t('transaction.column.tags') }}</div>
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="filterTag"
|
v-model:value="filterTag"
|
||||||
:options="tagFilterOptions"
|
:options="tagFilterOptions"
|
||||||
@@ -764,29 +767,29 @@ onMounted(() => {
|
|||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
:max-tag-count="2"
|
:max-tag-count="2"
|
||||||
placeholder="全部"
|
:placeholder="t('common.all')"
|
||||||
style="width: 100%;"
|
style="width: 100%;"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="filter-label">计入统计</div>
|
<div class="filter-label">{{ t('transaction.column.includeInStats') }}</div>
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="filterIncludeInStats"
|
v-model:value="filterIncludeInStats"
|
||||||
:options="includeInStatsOptions"
|
:options="includeInStatsOptions"
|
||||||
clearable
|
clearable
|
||||||
placeholder="全部"
|
:placeholder="t('common.all')"
|
||||||
style="width: 100%;"
|
style="width: 100%;"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="filter-label">金额区间</div>
|
<div class="filter-label">{{ t('transaction.amountRange') }}</div>
|
||||||
<NSpace align="center" :size="8">
|
<NSpace align="center" :size="8">
|
||||||
<NInputNumber
|
<NInputNumber
|
||||||
v-model:value="filterMinAmount"
|
v-model:value="filterMinAmount"
|
||||||
:min="0"
|
:min="0"
|
||||||
:precision="2"
|
:precision="2"
|
||||||
clearable
|
clearable
|
||||||
placeholder="最小"
|
:placeholder="t('common.min')"
|
||||||
style="width: 110px;"
|
style="width: 110px;"
|
||||||
/>
|
/>
|
||||||
<span style="color: #999;">~</span>
|
<span style="color: #999;">~</span>
|
||||||
@@ -795,30 +798,30 @@ onMounted(() => {
|
|||||||
:min="0"
|
:min="0"
|
||||||
:precision="2"
|
:precision="2"
|
||||||
clearable
|
clearable
|
||||||
placeholder="最大"
|
:placeholder="t('common.max')"
|
||||||
style="width: 110px;"
|
style="width: 110px;"
|
||||||
/>
|
/>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</div>
|
</div>
|
||||||
<NSpace justify="end">
|
<NSpace justify="end">
|
||||||
<NButton size="small" @click="clearMoreFilters">清空</NButton>
|
<NButton size="small" @click="clearMoreFilters">{{ t('transaction.clear') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NPopover>
|
</NPopover>
|
||||||
<NButton type="primary" @click="handleSearch">查询</NButton>
|
<NButton type="primary" @click="handleSearch">{{ t('transaction.search') }}</NButton>
|
||||||
<NButton @click="handleReset">重置</NButton>
|
<NButton @click="handleReset">{{ t('transaction.reset') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
<!-- 表格信息栏(右上角) -->
|
<!-- 表格信息栏(右上角) -->
|
||||||
<NSpace justify="end" align="center" :size="12" style="margin-bottom: 8px;">
|
<NSpace justify="end" align="center" :size="12" style="margin-bottom: 8px;">
|
||||||
<span style="color: #999; font-size: 12px;">
|
<span style="color: #999; font-size: 12px;">
|
||||||
总计 {{ total }} 条 · 第 {{ page }} / {{ totalPages }} 页 · 每页 {{ pageSize }} 条
|
{{ t('transaction.summary', { total, page, pages: totalPages, size: pageSize }) }}
|
||||||
</span>
|
</span>
|
||||||
<span style="font-size: 12px; color: #999;">
|
<span style="font-size: 12px; color: #999;">
|
||||||
本页支出 <span style="color: #d03050;">{{ pageSummary.expense.toFixed(2) }}</span>
|
{{ t('transaction.pageExpense') }} <span style="color: #d03050;">{{ pageSummary.expense.toFixed(2) }}</span>
|
||||||
· 本页收入 <span style="color: #18a058;">{{ pageSummary.income.toFixed(2) }}</span>
|
· {{ t('transaction.pageIncome') }} <span style="color: #18a058;">{{ pageSummary.income.toFixed(2) }}</span>
|
||||||
· 本页结余 <span :style="{ color: pageSummary.balance < 0 ? '#d03050' : '#18a058' }">{{ pageSummary.balance.toFixed(2) }}</span>
|
· {{ t('transaction.pageBalance') }} <span :style="{ color: pageSummary.balance < 0 ? '#d03050' : '#18a058' }">{{ pageSummary.balance.toFixed(2) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
@@ -840,41 +843,41 @@ onMounted(() => {
|
|||||||
</NSpace>
|
</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>
|
<NForm>
|
||||||
<NFormItem label="类型">
|
<NFormItem :label="t('transaction.column.type')">
|
||||||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="分类">
|
<NFormItem :label="t('transaction.column.category')">
|
||||||
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" placeholder="请选择分类" filterable />
|
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" :placeholder="t('transaction.selectCategoryPlaceholder')" filterable />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="金额">
|
<NFormItem :label="t('transaction.column.amount')">
|
||||||
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" placeholder="请输入金额" />
|
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="支付方式">
|
<NFormItem :label="t('transaction.column.paymentMethod')">
|
||||||
<NSelect v-model:value="form.paymentMethod" :options="paymentOptions" clearable placeholder="选择支付方式" />
|
<NSelect v-model:value="form.paymentMethod" :options="paymentOptions" clearable :placeholder="t('transaction.paymentPlaceholder')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="交易时间">
|
<NFormItem :label="t('transaction.column.transactionTime')">
|
||||||
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />
|
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="备注">
|
<NFormItem :label="t('transaction.column.remark')">
|
||||||
<NInput v-model:value="form.remark" type="textarea" placeholder="可选备注说明" />
|
<NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="标签">
|
<NFormItem :label="t('transaction.column.tags')">
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="form.tagIds"
|
v-model:value="form.tagIds"
|
||||||
:options="tagFilterOptions"
|
:options="tagFilterOptions"
|
||||||
multiple
|
multiple
|
||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
placeholder="选择标签"
|
:placeholder="t('transaction.tagPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="计入统计">
|
<NFormItem :label="t('transaction.includeInStats')">
|
||||||
<NSwitch v-model:value="form.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>
|
</NFormItem>
|
||||||
<NButton type="primary" block @click="handleSubmit">确定</NButton>
|
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
||||||
</NForm>
|
</NForm>
|
||||||
</NModal>
|
</NModal>
|
||||||
|
|
||||||
@@ -882,48 +885,48 @@ onMounted(() => {
|
|||||||
<BillImportModal v-model:show="showImportModal" @imported="fetchData" />
|
<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 }" />
|
<NInput :value="rawDataText" type="textarea" readonly :autosize="{ minRows: 8, maxRows: 20 }" />
|
||||||
</NModal>
|
</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>
|
<NForm>
|
||||||
<NFormItem label="作用范围">
|
<NFormItem :label="t('transaction.batchScope')">
|
||||||
<span style="color: #999; font-size: 12px;">
|
<span style="color: #999; font-size: 12px;">
|
||||||
将对当前表格展示的 {{ transactions.length }} 条记录设置分类与计入统计
|
{{ t('transaction.batchScopeHint', { count: transactions.length }) }}
|
||||||
</span>
|
</span>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="支出分类">
|
<NFormItem :label="t('transaction.expenseCategory')">
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="batchExpenseCategoryId"
|
v-model:value="batchExpenseCategoryId"
|
||||||
:options="categories.filter(c => c.type === 'EXPENSE')"
|
:options="categories.filter(c => c.type === 'EXPENSE')"
|
||||||
:render-label="renderCategoryLabel"
|
:render-label="renderCategoryLabel"
|
||||||
filterable
|
filterable
|
||||||
clearable
|
clearable
|
||||||
placeholder="不设置支出分类"
|
:placeholder="t('transaction.notSetExpense')"
|
||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="收入分类">
|
<NFormItem :label="t('transaction.incomeCategory')">
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="batchIncomeCategoryId"
|
v-model:value="batchIncomeCategoryId"
|
||||||
:options="categories.filter(c => c.type === 'INCOME')"
|
:options="categories.filter(c => c.type === 'INCOME')"
|
||||||
:render-label="renderCategoryLabel"
|
:render-label="renderCategoryLabel"
|
||||||
filterable
|
filterable
|
||||||
clearable
|
clearable
|
||||||
placeholder="不设置收入分类"
|
:placeholder="t('transaction.notSetIncome')"
|
||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="计入统计">
|
<NFormItem :label="t('transaction.includeInStats')">
|
||||||
<NSelect v-model:value="batchIncludeInStats" :options="batchStatsOptions" />
|
<NSelect v-model:value="batchIncludeInStats" :options="batchStatsOptions" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="添加标签">
|
<NFormItem :label="t('transaction.addTag')">
|
||||||
<NSelect v-model:value="batchAddTagIds" :options="tagFilterOptions" multiple clearable filterable placeholder="不添加标签" />
|
<NSelect v-model:value="batchAddTagIds" :options="tagFilterOptions" multiple clearable filterable :placeholder="t('transaction.notAddTag')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NFormItem label="移除标签">
|
<NFormItem :label="t('transaction.removeTag')">
|
||||||
<NSelect v-model:value="batchRemoveTagIds" :options="tagFilterOptions" multiple clearable filterable placeholder="不移除标签" />
|
<NSelect v-model:value="batchRemoveTagIds" :options="tagFilterOptions" multiple clearable filterable :placeholder="t('transaction.notRemoveTag')" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<NButton type="primary" block :loading="batching" @click="handleBatchSubmit">确定</NButton>
|
<NButton type="primary" block :loading="batching" @click="handleBatchSubmit">{{ t('common.confirm') }}</NButton>
|
||||||
</NForm>
|
</NForm>
|
||||||
</NModal>
|
</NModal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user