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

- 引入 vue-i18n,新增 locales/messages.ts 以中英对照方式维护词条
- 新增 LocaleSwitch 组件,在登录/注册页与主布局顶部栏提供切换入口
- 语言持久化到 localStorage 并同步 <html lang>,Naive UI 组件语言联动
- 各视图、表格列、弹窗与提示文案全部接入国际化
- 修复 confirmImport 缺失的 tagIds 类型定义
This commit is contained in:
2026-09-17 17:03:49 +08:00
parent 65c0d8c550
commit 179e9b78ca
15 changed files with 806 additions and 351 deletions
+55 -46
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与支付方式分布
import { ref, computed, onMounted, watch, nextTick, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
@@ -13,21 +14,23 @@ import { getCategoryList } from '../api/category'
// 注册 ECharts 所需组件
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
const { t, locale } = useI18n() // 国际化
// 时间范围快捷选项
const rangeOptions = [
{ label: '本月', value: 'month' },
{ label: '近3月', value: '3month' },
{ label: '近12月', value: '12month' },
{ label: '自定义', value: 'custom' },
]
const rangeOptions = computed(() => [
{ label: t('dashboard.range.month'), value: 'month' },
{ label: t('dashboard.range.3month'), value: '3month' },
{ label: t('dashboard.range.12month'), value: '12month' },
{ label: t('dashboard.range.custom'), value: 'custom' },
])
// 趋势图时间粒度选项
const granularityOptions = [
{ label: '按日', value: 'day' },
{ label: '按周', value: 'week' },
{ label: '按月', value: 'month' },
{ label: '按季', value: 'quarter' },
{ label: '按年', value: 'year' },
]
const granularityOptions = computed(() => [
{ label: t('dashboard.granularity.day'), value: 'day' },
{ label: t('dashboard.granularity.week'), value: 'week' },
{ label: t('dashboard.granularity.month'), value: 'month' },
{ label: t('dashboard.granularity.quarter'), value: 'quarter' },
{ label: t('dashboard.granularity.year'), value: 'year' },
])
const rangeType = ref<'month' | '3month' | '12month' | 'custom'>('month') // 时间范围类型
const customRange = ref<[number, number] | null>(null) // 自定义起止时间戳
@@ -42,16 +45,16 @@ const filterMaxAmount = ref<number | null>(null) // 最大金额
const filterRemark = ref('') // 备注关键字
// 类型与支付方式选项
const typeOptions = [
{ label: '支出', value: 'EXPENSE' },
{ label: '收入', value: 'INCOME' },
]
const paymentOptions = [
{ label: '微信', value: 'WECHAT' },
{ label: '支付宝', value: 'ALIPAY' },
{ label: '现金', value: 'CASH' },
{ label: '银行卡', value: 'BANK' },
]
const typeOptions = computed(() => [
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
const paymentOptions = computed(() => [
{ label: t('common.payment.wechat'), value: 'WECHAT' },
{ label: t('common.payment.alipay'), value: 'ALIPAY' },
{ label: t('common.payment.cash'), value: 'CASH' },
{ label: t('common.payment.bank'), value: 'BANK' },
])
/** 分类选项数据结构 */
interface CategoryOption {
@@ -64,7 +67,7 @@ interface CategoryOption {
const categories = ref<CategoryOption[]>([]) // 分类选项列表
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
const categoryFilterOptions = computed(() => [
{ label: '未分类', value: 0, type: '' },
{ label: t('dashboard.uncategorized'), value: 0, type: '' },
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
])
@@ -180,7 +183,7 @@ function buildKeys(start: Date, end: Date, gran: string): string[] {
/** 更新支出分类饼图 */
function updatePieChart() {
pieOption.value = {
title: { text: '支出分类占比', left: 'center' },
title: { text: t('dashboard.expenseCategoryPie'), left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
@@ -197,7 +200,7 @@ function updatePieChart() {
/** 更新支付方式分布饼图 */
function updatePaymentChart() {
paymentOption.value = {
title: { text: '支付方式分布', left: 'center' },
title: { text: t('dashboard.paymentPie'), left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
@@ -222,13 +225,13 @@ function updateTrendChart() {
trendOption.value = {
tooltip: { trigger: 'axis' },
legend: { data: ['收入', '支出'], bottom: 0 },
legend: { data: [t('common.types.income'), t('common.types.expense')], bottom: 0 },
grid: { left: 60, right: 20, top: 40, bottom: 40 },
xAxis: { type: 'category', data: labels },
yAxis: { type: 'value' },
series: [
{ name: '收入', type: 'bar', data: income, color: '#18a058' },
{ name: '支出', type: 'bar', data: expense, color: '#d03050' },
{ name: t('common.types.income'), type: 'bar', data: income, color: '#18a058' },
{ name: t('common.types.expense'), type: 'bar', data: expense, color: '#d03050' },
],
}
}
@@ -332,6 +335,12 @@ watch(rangeType, (v) => {
watch([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData)
// 任一偏好变化时自动保存
watch([rangeType, customRange, granularity, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
// 语言切换时重绘图表,更新标题与图例文案
watch(locale, () => {
updatePieChart()
updatePaymentChart()
updateTrendChart()
})
onMounted(async () => {
loadPrefs()
@@ -346,7 +355,7 @@ onMounted(async () => {
<div>
<!-- 顶部:标题与时间范围选择 -->
<NSpace justify="space-between" align="center" style="margin-bottom: 20px;" :wrap="true">
<h2 style="margin: 0;">统计面板</h2>
<h2 style="margin: 0;">{{ t('dashboard.title') }}</h2>
<NSpace align="center" :wrap="true">
<NSelect v-model:value="rangeType" :options="rangeOptions" style="width: 110px;" />
<NDatePicker
@@ -369,7 +378,7 @@ onMounted(async () => {
multiple
clearable
:max-tag-count="2"
placeholder="类型"
:placeholder="t('dashboard.filterType')"
style="width: 160px;"
/>
<NSelect
@@ -380,7 +389,7 @@ onMounted(async () => {
filterable
:max-tag-count="2"
:render-label="renderCategoryLabel"
placeholder="分类"
:placeholder="t('dashboard.filterCategory')"
style="width: 200px;"
/>
<NSelect
@@ -389,7 +398,7 @@ onMounted(async () => {
multiple
clearable
:max-tag-count="2"
placeholder="支付方式"
:placeholder="t('dashboard.filterPaymentMethod')"
style="width: 200px;"
/>
<NInputNumber
@@ -397,7 +406,7 @@ onMounted(async () => {
:min="0"
:precision="2"
clearable
placeholder="最小金额"
:placeholder="t('dashboard.minAmount')"
style="width: 130px;"
/>
<span style="color: #999;">~</span>
@@ -406,16 +415,16 @@ onMounted(async () => {
:min="0"
:precision="2"
clearable
placeholder="最大金额"
:placeholder="t('dashboard.maxAmount')"
style="width: 130px;"
/>
<NInput
v-model:value="filterRemark"
clearable
placeholder="备注搜索"
:placeholder="t('dashboard.remark')"
style="width: 180px;"
/>
<NButton @click="handleResetFilters">重置筛选</NButton>
<NButton @click="handleResetFilters">{{ t('dashboard.resetFilters') }}</NButton>
</NSpace>
</NCard>
@@ -423,26 +432,26 @@ onMounted(async () => {
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 16px;">
<NGridItem>
<NCard>
<NStatistic label="总收入" :value="Number(summary.totalIncome).toFixed(2)" style="color: #18a058;">
<NStatistic :label="t('dashboard.totalIncome')" :value="Number(summary.totalIncome).toFixed(2)" style="color: #18a058;">
<template #prefix>+</template>
</NStatistic>
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="总支出" :value="Number(summary.totalExpense).toFixed(2)" style="color: #d03050;">
<NStatistic :label="t('dashboard.totalExpense')" :value="Number(summary.totalExpense).toFixed(2)" style="color: #d03050;">
<template #prefix>-</template>
</NStatistic>
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="结余" :value="Number(summary.balance).toFixed(2)" />
<NStatistic :label="t('dashboard.balance')" :value="Number(summary.balance).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="交易笔数" :value="summary.transactionCount" />
<NStatistic :label="t('dashboard.transactionCount')" :value="summary.transactionCount" />
</NCard>
</NGridItem>
</NGrid>
@@ -451,17 +460,17 @@ onMounted(async () => {
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
<NGridItem>
<NCard>
<NStatistic label="日均收入" :value="Number(summary.avgDailyIncome).toFixed(2)" />
<NStatistic :label="t('dashboard.avgDailyIncome')" :value="Number(summary.avgDailyIncome).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="日均支出" :value="Number(summary.avgDailyExpense).toFixed(2)" />
<NStatistic :label="t('dashboard.avgDailyExpense')" :value="Number(summary.avgDailyExpense).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="最大单笔支出" :value="Number(summary.maxExpenseAmount).toFixed(2)">
<NStatistic :label="t('dashboard.maxExpense')" :value="Number(summary.maxExpenseAmount).toFixed(2)">
<template #suffix>
<span style="font-size: 12px; color: #999;">{{ (summary.maxExpenseRemark || '').slice(0, 12) }}</span>
</template>
@@ -470,7 +479,7 @@ onMounted(async () => {
</NGridItem>
<NGridItem>
<NCard>
<NStatistic label="平均每笔支出" :value="Number(summary.avgExpenseAmount).toFixed(2)" />
<NStatistic :label="t('dashboard.avgExpense')" :value="Number(summary.avgExpenseAmount).toFixed(2)" />
</NCard>
</NGridItem>
</NGrid>
@@ -479,7 +488,7 @@ onMounted(async () => {
<NCard style="margin-bottom: 16px;">
<template #header>
<NSpace justify="space-between" align="center">
<span>收支趋势</span>
<span>{{ t('dashboard.trend') }}</span>
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" />
</NSpace>
</template>