feat: 增强财务管理系统功能与分析能力

主要更新:
- 🎯 新增综合分析仪表板,包含关键指标卡片、预算对比、智能洞察等组件
- 📊 增强数据可视化能力,新增标签云分析、时间维度分析等图表
- 📱 优化移动端响应式设计,改进触控交互体验
- 🔧 新增多个API模块(base、budget、tag),完善数据管理
- 🗂️ 重构路由结构,新增贷款、快速添加、设置、统计等独立模块
- 🔄 优化数据导入导出功能,增强数据迁移能力
- 🐛 修复多个已知问题,提升系统稳定性

技术改进:
- 使用IndexedDB提升本地存储性能
- 实现模拟API服务,支持离线开发
- 增加自动化测试脚本,确保功能稳定
- 优化打包配置,提升构建效率

文件变更:
- 新增42个文件
- 修改55个文件
- 包含测试脚本、配置文件、组件和API模块

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
你的用户名
2025-08-24 16:41:58 +08:00
parent 4b4616de1e
commit 675fe0a1a8
154 changed files with 10035 additions and 3978 deletions

View File

@@ -1,172 +1,8 @@
<template>
<div class="mobile-quick-add">
<div class="quick-add-header">
<Button
type="text"
@click="handleClose"
style="position: absolute; left: 8px; top: 8px;"
>
<CloseOutlined />
</Button>
<h3>快速记账</h3>
</div>
<div class="quick-add-body">
<!-- 交易类型切换 -->
<div class="type-switcher">
<Button
:type="formData.type === 'expense' ? 'primary' : 'default'"
@click="formData.type = 'expense'"
block
>
支出
</Button>
<Button
:type="formData.type === 'income' ? 'primary' : 'default'"
@click="formData.type = 'income'"
block
>
收入
</Button>
</div>
<!-- 金额输入 -->
<div class="amount-input-wrapper">
<div class="currency-symbol">¥</div>
<input
ref="amountInputRef"
v-model="amountDisplay"
type="text"
class="amount-input"
placeholder="0.00"
@input="handleAmountInput"
@keyup.enter="handleQuickSave"
/>
</div>
<!-- 分类选择 -->
<div class="category-grid">
<div
v-for="category in quickCategories"
:key="category.id"
:class="['category-item', { active: formData.categoryId === category.id }]"
@click="formData.categoryId = category.id"
>
<div class="category-icon">{{ category.icon || '📁' }}</div>
<div class="category-name">{{ category.name }}</div>
</div>
<div class="category-item more" @click="showAllCategories = true">
<div class="category-icon">
<EllipsisOutlined />
</div>
<div class="category-name">更多</div>
</div>
</div>
<!-- 可选信息 -->
<div class="optional-fields">
<div class="field-item" @click="showDatePicker = true">
<CalendarOutlined />
<span>{{ dayjs(formData.date).format('MM月DD日') }}</span>
<RightOutlined />
</div>
<div class="field-item" @click="showDescriptionInput = true">
<EditOutlined />
<span>{{ formData.description || '添加备注' }}</span>
<RightOutlined />
</div>
<div class="field-item" @click="showTagSelector = true">
<TagsOutlined />
<span>
{{ selectedTagNames.length > 0 ? selectedTagNames.join(', ') : '添加标签' }}
</span>
<RightOutlined />
</div>
</div>
<!-- 保存按钮 -->
<div class="save-button-wrapper">
<Button
type="primary"
size="large"
block
:loading="saving"
:disabled="!canSave"
@click="handleSave"
>
保存
</Button>
</div>
</div>
<!-- 所有分类抽屉 -->
<Drawer
v-model:open="showAllCategories"
title="选择分类"
placement="bottom"
:height="'60%'"
>
<div class="all-categories">
<div
v-for="category in filteredCategories"
:key="category.id"
:class="['category-full-item', { active: formData.categoryId === category.id }]"
@click="selectCategory(category.id)"
>
<span class="category-icon">{{ category.icon || '📁' }}</span>
<span class="category-name">{{ category.name }}</span>
<CheckOutlined v-if="formData.categoryId === category.id" />
</div>
</div>
</Drawer>
<!-- 日期选择器 -->
<Modal
v-model:open="showDatePicker"
title="选择日期"
width="90%"
:footer="null"
>
<DatePicker
v-model:value="formData.date"
style="width: 100%"
@change="showDatePicker = false"
/>
</Modal>
<!-- 备注输入 -->
<Modal
v-model:open="showDescriptionInput"
title="添加备注"
width="90%"
@ok="showDescriptionInput = false"
>
<TextArea
v-model:value="formData.description"
:rows="4"
placeholder="输入备注信息"
:maxlength="200"
showCount
/>
</Modal>
<!-- 标签选择 -->
<Modal
v-model:open="showTagSelector"
title="选择标签"
width="90%"
@ok="showTagSelector = false"
>
<TagSelector v-model:value="formData.tags" />
</Modal>
</div>
</template>
<script setup lang="ts">
import type { Transaction } from '#/types/finance';
import { computed, nextTick, onMounted, ref } from 'vue';
import {
CalendarOutlined,
CheckOutlined,
@@ -181,24 +17,23 @@ import {
DatePicker,
Drawer,
Input,
Modal,
message,
Modal,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { computed, nextTick, onMounted, ref } from 'vue';
import { useCategoryStore } from '#/store/modules/category';
import { useTagStore } from '#/store/modules/tag';
import { useTransactionStore } from '#/store/modules/transaction';
import TagSelector from '#/views/finance/tag/components/tag-selector.vue';
const { TextArea } = Input;
const emit = defineEmits<{
close: [];
saved: [transaction: Transaction];
}>();
const { TextArea } = Input;
const categoryStore = useCategoryStore();
const tagStore = useTagStore();
const transactionStore = useTransactionStore();
@@ -226,43 +61,44 @@ const formData = ref<Partial<Transaction>>({
// 快速访问的分类最常用的6个
const quickCategories = computed(() => {
const categories = categoryStore.categories
.filter(c => c.type === formData.value.type)
.filter((c) => c.type === formData.value.type)
.slice(0, 5);
return categories;
});
const filteredCategories = computed(() =>
categoryStore.categories.filter(c => c.type === formData.value.type)
const filteredCategories = computed(() =>
categoryStore.categories.filter((c) => c.type === formData.value.type),
);
const selectedTagNames = computed(() => {
if (!formData.value.tags || formData.value.tags.length === 0) return [];
return formData.value.tags
.map(tagId => tagStore.tagMap.get(tagId)?.name)
.map((tagId) => tagStore.tagMap.get(tagId)?.name)
.filter(Boolean) as string[];
});
const canSave = computed(() =>
formData.value.amount &&
formData.value.amount > 0 &&
formData.value.categoryId
const canSave = computed(
() =>
formData.value.amount &&
formData.value.amount > 0 &&
formData.value.categoryId,
);
const handleAmountInput = (e: Event) => {
const input = e.target as HTMLInputElement;
let value = input.value.replace(/[^\d.]/g, '');
let value = input.value.replaceAll(/[^\d.]/g, '');
// 处理小数点
const parts = value.split('.');
if (parts.length > 2) {
value = parts[0] + '.' + parts.slice(1).join('');
value = `${parts[0]}.${parts.slice(1).join('')}`;
}
if (parts[1]?.length > 2) {
value = parts[0] + '.' + parts[1].slice(0, 2);
value = `${parts[0]}.${parts[1].slice(0, 2)}`;
}
amountDisplay.value = value;
formData.value.amount = parseFloat(value) || 0;
formData.value.amount = Number.parseFloat(value) || 0;
};
const selectCategory = (categoryId: string) => {
@@ -278,7 +114,7 @@ const handleQuickSave = () => {
const handleSave = async () => {
if (!canSave.value) return;
saving.value = true;
try {
const transaction = await transactionStore.createTransaction({
@@ -286,10 +122,10 @@ const handleSave = async () => {
amount: formData.value.amount!,
recorder: '管理员',
});
message.success('记账成功');
emit('saved', transaction as Transaction);
// 重置表单
formData.value = {
type: formData.value.type,
@@ -302,12 +138,12 @@ const handleSave = async () => {
tags: [],
};
amountDisplay.value = '';
// 重新聚焦金额输入框
nextTick(() => {
amountInputRef.value?.focus();
});
} catch (error) {
} catch {
message.error('记账失败');
} finally {
saving.value = false;
@@ -326,17 +162,210 @@ onMounted(() => {
});
</script>
<template>
<div class="mobile-quick-add">
<div class="quick-add-header">
<Button
type="text"
@click="handleClose"
style="position: absolute; top: 8px; left: 8px"
>
<CloseOutlined />
</Button>
<h3>快速记账</h3>
</div>
<div class="quick-add-body">
<!-- 交易类型切换 -->
<div class="type-switcher">
<Button
:type="formData.type === 'expense' ? 'primary' : 'default'"
@click="formData.type = 'expense'"
block
>
支出
</Button>
<Button
:type="formData.type === 'income' ? 'primary' : 'default'"
@click="formData.type = 'income'"
block
>
收入
</Button>
</div>
<!-- 金额输入 -->
<div class="amount-input-wrapper">
<div class="currency-symbol">¥</div>
<input
ref="amountInputRef"
v-model="amountDisplay"
type="text"
class="amount-input"
placeholder="0.00"
@input="handleAmountInput"
@keyup.enter="handleQuickSave"
/>
</div>
<!-- 分类选择 -->
<div class="category-grid">
<div
v-for="category in quickCategories"
:key="category.id"
class="category-item"
:class="[{ active: formData.categoryId === category.id }]"
@click="formData.categoryId = category.id"
>
<div class="category-icon">{{ category.icon || '📁' }}</div>
<div class="category-name">{{ category.name }}</div>
</div>
<div class="category-item more" @click="showAllCategories = true">
<div class="category-icon">
<EllipsisOutlined />
</div>
<div class="category-name">更多</div>
</div>
</div>
<!-- 可选信息 -->
<div class="optional-fields">
<div class="field-item" @click="showDatePicker = true">
<CalendarOutlined />
<span>{{ dayjs(formData.date).format('MM月DD日') }}</span>
<RightOutlined />
</div>
<div class="field-item" @click="showDescriptionInput = true">
<EditOutlined />
<span>{{ formData.description || '添加备注' }}</span>
<RightOutlined />
</div>
<div class="field-item" @click="showTagSelector = true">
<TagsOutlined />
<span>
{{
selectedTagNames.length > 0
? selectedTagNames.join(', ')
: '添加标签'
}}
</span>
<RightOutlined />
</div>
</div>
<!-- 保存按钮 -->
<div class="save-button-wrapper">
<Button
type="primary"
size="large"
block
:loading="saving"
:disabled="!canSave"
@click="handleSave"
>
保存
</Button>
</div>
</div>
<!-- 所有分类抽屉 -->
<Drawer
v-model:open="showAllCategories"
title="选择分类"
placement="bottom"
height="60%"
>
<div class="all-categories">
<div
v-for="category in filteredCategories"
:key="category.id"
class="category-full-item"
:class="[{ active: formData.categoryId === category.id }]"
@click="selectCategory(category.id)"
>
<span class="category-icon">{{ category.icon || '📁' }}</span>
<span class="category-name">{{ category.name }}</span>
<CheckOutlined v-if="formData.categoryId === category.id" />
</div>
</div>
</Drawer>
<!-- 日期选择器 -->
<Modal
v-model:open="showDatePicker"
title="选择日期"
width="90%"
:footer="null"
>
<DatePicker
v-model:value="formData.date"
style="width: 100%"
@change="showDatePicker = false"
/>
</Modal>
<!-- 备注输入 -->
<Modal
v-model:open="showDescriptionInput"
title="添加备注"
width="90%"
@ok="showDescriptionInput = false"
>
<TextArea
v-model:value="formData.description"
:rows="4"
placeholder="输入备注信息"
:maxlength="200"
show-count
/>
</Modal>
<!-- 标签选择 -->
<Modal
v-model:open="showTagSelector"
title="选择标签"
width="90%"
@ok="showTagSelector = false"
>
<TagSelector v-model:value="formData.tags" />
</Modal>
</div>
</template>
<style scoped>
/* 移动端优化 */
@media (max-width: 768px) {
.quick-add-body {
padding: 12px;
}
.category-grid {
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.category-item {
padding: 8px 4px;
}
.category-icon {
font-size: 20px;
}
.category-name {
font-size: 11px;
}
}
.mobile-quick-add {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #fff;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
background: #fff;
}
.quick-add-header {
@@ -368,25 +397,25 @@ onMounted(() => {
.amount-input-wrapper {
display: flex;
align-items: center;
margin-bottom: 24px;
padding: 12px 0;
margin-bottom: 24px;
border-bottom: 2px solid #1890ff;
}
.currency-symbol {
margin-right: 8px;
font-size: 24px;
color: #1890ff;
margin-right: 8px;
}
.amount-input {
flex: 1;
font-size: 36px;
font-weight: 500;
border: none;
outline: none;
text-align: right;
color: #262626;
text-align: right;
outline: none;
border: none;
}
.amount-input::placeholder {
@@ -405,9 +434,9 @@ onMounted(() => {
flex-direction: column;
align-items: center;
padding: 12px 8px;
cursor: pointer;
border: 1px solid #f0f0f0;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
@@ -416,8 +445,8 @@ onMounted(() => {
}
.category-item.active {
background: rgb(24 144 255 / 5%);
border-color: #1890ff;
background: rgba(24, 144, 255, 0.05);
}
.category-item.more {
@@ -425,8 +454,8 @@ onMounted(() => {
}
.category-icon {
font-size: 24px;
margin-bottom: 4px;
font-size: 24px;
}
.category-name {
@@ -443,8 +472,8 @@ onMounted(() => {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.field-item:active {
@@ -460,8 +489,8 @@ onMounted(() => {
.save-button-wrapper {
position: sticky;
bottom: 0;
background: #fff;
padding: 16px 0;
background: #fff;
}
.all-categories {
@@ -473,8 +502,8 @@ onMounted(() => {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.category-full-item:active {
@@ -486,35 +515,11 @@ onMounted(() => {
}
.category-full-item .category-icon {
font-size: 20px;
margin-right: 12px;
font-size: 20px;
}
.category-full-item .category-name {
flex: 1;
}
/* 移动端优化 */
@media (max-width: 768px) {
.quick-add-body {
padding: 12px;
}
.category-grid {
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.category-item {
padding: 8px 4px;
}
.category-icon {
font-size: 20px;
}
.category-name {
font-size: 11px;
}
}
</style>
</style>