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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user