主要更新: - 🎯 新增综合分析仪表板,包含关键指标卡片、预算对比、智能洞察等组件 - 📊 增强数据可视化能力,新增标签云分析、时间维度分析等图表 - 📱 优化移动端响应式设计,改进触控交互体验 - 🔧 新增多个API模块(base、budget、tag),完善数据管理 - 🗂️ 重构路由结构,新增贷款、快速添加、设置、统计等独立模块 - 🔄 优化数据导入导出功能,增强数据迁移能力 - 🐛 修复多个已知问题,提升系统稳定性 技术改进: - 使用IndexedDB提升本地存储性能 - 实现模拟API服务,支持离线开发 - 增加自动化测试脚本,确保功能稳定 - 优化打包配置,提升构建效率 文件变更: - 新增42个文件 - 修改55个文件 - 包含测试脚本、配置文件、组件和API模块 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
526 lines
11 KiB
Vue
526 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import type { Transaction } from '#/types/finance';
|
||
|
||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||
|
||
import {
|
||
CalendarOutlined,
|
||
CheckOutlined,
|
||
CloseOutlined,
|
||
EditOutlined,
|
||
EllipsisOutlined,
|
||
RightOutlined,
|
||
TagsOutlined,
|
||
} from '@ant-design/icons-vue';
|
||
import {
|
||
Button,
|
||
DatePicker,
|
||
Drawer,
|
||
Input,
|
||
message,
|
||
Modal,
|
||
} from 'ant-design-vue';
|
||
import dayjs from 'dayjs';
|
||
|
||
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 emit = defineEmits<{
|
||
close: [];
|
||
saved: [transaction: Transaction];
|
||
}>();
|
||
|
||
const { TextArea } = Input;
|
||
|
||
const categoryStore = useCategoryStore();
|
||
const tagStore = useTagStore();
|
||
const transactionStore = useTransactionStore();
|
||
|
||
const amountInputRef = ref<HTMLInputElement>();
|
||
const amountDisplay = ref('');
|
||
const saving = ref(false);
|
||
|
||
const showAllCategories = ref(false);
|
||
const showDatePicker = ref(false);
|
||
const showDescriptionInput = ref(false);
|
||
const showTagSelector = ref(false);
|
||
|
||
const formData = ref<Partial<Transaction>>({
|
||
type: 'expense',
|
||
amount: 0,
|
||
categoryId: '',
|
||
currency: 'CNY',
|
||
date: dayjs().format('YYYY-MM-DD'),
|
||
description: '',
|
||
status: 'completed',
|
||
tags: [],
|
||
});
|
||
|
||
// 快速访问的分类(最常用的6个)
|
||
const quickCategories = computed(() => {
|
||
const categories = categoryStore.categories
|
||
.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 selectedTagNames = computed(() => {
|
||
if (!formData.value.tags || formData.value.tags.length === 0) return [];
|
||
return formData.value.tags
|
||
.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 handleAmountInput = (e: Event) => {
|
||
const input = e.target as HTMLInputElement;
|
||
let value = input.value.replaceAll(/[^\d.]/g, '');
|
||
|
||
// 处理小数点
|
||
const parts = value.split('.');
|
||
if (parts.length > 2) {
|
||
value = `${parts[0]}.${parts.slice(1).join('')}`;
|
||
}
|
||
if (parts[1]?.length > 2) {
|
||
value = `${parts[0]}.${parts[1].slice(0, 2)}`;
|
||
}
|
||
|
||
amountDisplay.value = value;
|
||
formData.value.amount = Number.parseFloat(value) || 0;
|
||
};
|
||
|
||
const selectCategory = (categoryId: string) => {
|
||
formData.value.categoryId = categoryId;
|
||
showAllCategories.value = false;
|
||
};
|
||
|
||
const handleQuickSave = () => {
|
||
if (canSave.value) {
|
||
handleSave();
|
||
}
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!canSave.value) return;
|
||
|
||
saving.value = true;
|
||
try {
|
||
const transaction = await transactionStore.createTransaction({
|
||
...formData.value,
|
||
amount: formData.value.amount!,
|
||
recorder: '管理员',
|
||
});
|
||
|
||
message.success('记账成功');
|
||
emit('saved', transaction as Transaction);
|
||
|
||
// 重置表单
|
||
formData.value = {
|
||
type: formData.value.type,
|
||
amount: 0,
|
||
categoryId: '',
|
||
currency: 'CNY',
|
||
date: dayjs().format('YYYY-MM-DD'),
|
||
description: '',
|
||
status: 'completed',
|
||
tags: [],
|
||
};
|
||
amountDisplay.value = '';
|
||
|
||
// 重新聚焦金额输入框
|
||
nextTick(() => {
|
||
amountInputRef.value?.focus();
|
||
});
|
||
} catch {
|
||
message.error('记账失败');
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
};
|
||
|
||
const handleClose = () => {
|
||
emit('close');
|
||
};
|
||
|
||
onMounted(() => {
|
||
// 自动聚焦金额输入框
|
||
nextTick(() => {
|
||
amountInputRef.value?.focus();
|
||
});
|
||
});
|
||
</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;
|
||
inset: 0;
|
||
z-index: 1000;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #fff;
|
||
}
|
||
|
||
.quick-add-header {
|
||
position: relative;
|
||
padding: 16px;
|
||
text-align: center;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.quick-add-header h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.quick-add-body {
|
||
flex: 1;
|
||
padding: 16px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.type-switcher {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.amount-input-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 12px 0;
|
||
margin-bottom: 24px;
|
||
border-bottom: 2px solid #1890ff;
|
||
}
|
||
|
||
.currency-symbol {
|
||
margin-right: 8px;
|
||
font-size: 24px;
|
||
color: #1890ff;
|
||
}
|
||
|
||
.amount-input {
|
||
flex: 1;
|
||
font-size: 36px;
|
||
font-weight: 500;
|
||
color: #262626;
|
||
text-align: right;
|
||
outline: none;
|
||
border: none;
|
||
}
|
||
|
||
.amount-input::placeholder {
|
||
color: #bfbfbf;
|
||
}
|
||
|
||
.category-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 12px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.category-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 12px 8px;
|
||
cursor: pointer;
|
||
border: 1px solid #f0f0f0;
|
||
border-radius: 8px;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.category-item:active {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.category-item.active {
|
||
background: rgb(24 144 255 / 5%);
|
||
border-color: #1890ff;
|
||
}
|
||
|
||
.category-item.more {
|
||
border-style: dashed;
|
||
}
|
||
|
||
.category-icon {
|
||
margin-bottom: 4px;
|
||
font-size: 24px;
|
||
}
|
||
|
||
.category-name {
|
||
font-size: 12px;
|
||
color: #595959;
|
||
text-align: center;
|
||
}
|
||
|
||
.optional-fields {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.field-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 12px 0;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.field-item:active {
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.field-item > span {
|
||
flex: 1;
|
||
margin: 0 12px;
|
||
color: #595959;
|
||
}
|
||
|
||
.save-button-wrapper {
|
||
position: sticky;
|
||
bottom: 0;
|
||
padding: 16px 0;
|
||
background: #fff;
|
||
}
|
||
|
||
.all-categories {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.category-full-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 12px 0;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.category-full-item:active {
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.category-full-item.active {
|
||
color: #1890ff;
|
||
}
|
||
|
||
.category-full-item .category-icon {
|
||
margin-right: 12px;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.category-full-item .category-name {
|
||
flex: 1;
|
||
}
|
||
</style>
|