refactor: 整合财务系统到主应用并重构后端架构

主要变更:
- 将独立的 web-finance 应用整合到 web-antd 主应用中
- 重命名 backend-mock 为 backend,增强后端功能
- 新增财务模块 API 端点(账户、预算、类别、交易)
- 增强财务仪表板和报表功能
- 添加 SQLite 数据存储支持和财务数据导入脚本
- 优化路由结构,删除冗余的 finance-system 模块

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
woshiqp465
2025-10-04 21:14:21 +08:00
parent 9683b940bf
commit 1e42191296
275 changed files with 10221 additions and 22207 deletions

View File

@@ -30,6 +30,96 @@
</head>
<body>
<div id="app"></div>
<script>
// Flatten FinWise Pro menu - Remove parent menu and show children directly
(function() {
console.log('[FinWise] Script loaded');
function flattenFinWiseProMenu() {
console.log('[FinWise] flattenFinWiseProMenu called');
const submenus = document.querySelectorAll('.vben-sub-menu');
console.log('[FinWise] Found submenus:', submenus.length);
let finwiseMenu = null;
submenus.forEach(menu => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl && titleEl.textContent) {
console.log('[FinWise] Menu title:', titleEl.textContent.trim());
if (titleEl.textContent.includes('FinWise Pro')) {
finwiseMenu = menu;
console.log('[FinWise] Found FinWise Pro menu!');
}
}
});
if (!finwiseMenu) {
console.log('[FinWise] FinWise Pro menu not found');
return;
}
const parentMenu = finwiseMenu.parentElement;
const childrenUL = finwiseMenu.querySelector('.vben-menu');
if (!childrenUL || !parentMenu) {
console.log('[FinWise] No children or parent');
return;
}
// Check if already processed
if (finwiseMenu.style.display === 'none') {
console.log('[FinWise] Already processed');
return;
}
// Move all children to the parent menu
const children = Array.from(childrenUL.children);
console.log('[FinWise] Moving', children.length, 'children');
children.forEach(child => {
parentMenu.insertBefore(child, finwiseMenu);
});
// Hide the FinWise Pro submenu
finwiseMenu.style.display = 'none';
console.log('[FinWise] Successfully flattened menu!');
}
// Run after DOM loads
const delays = [500, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7000, 8000];
console.log('[FinWise] Setting up delays:', delays);
if (document.readyState === 'loading') {
console.log('[FinWise] Waiting for DOMContentLoaded');
document.addEventListener('DOMContentLoaded', function() {
console.log('[FinWise] DOMContentLoaded fired');
delays.forEach(delay => {
setTimeout(flattenFinWiseProMenu, delay);
});
});
} else {
console.log('[FinWise] DOM already loaded');
delays.forEach(delay => {
setTimeout(flattenFinWiseProMenu, delay);
});
}
// Watch for DOM changes
setTimeout(function() {
console.log('[FinWise] Setting up MutationObserver');
const observer = new MutationObserver(function() {
setTimeout(flattenFinWiseProMenu, 200);
});
const body = document.body;
if (body) {
observer.observe(body, {
childList: true,
subtree: true
});
console.log('[FinWise] MutationObserver active');
}
}, 500);
})();
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -43,8 +43,11 @@
"@vueuse/core": "catalog:",
"ant-design-vue": "catalog:",
"dayjs": "catalog:",
"echarts": "catalog:",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
"vue-echarts": "^8.0.0",
"vue-router": "catalog:",
"xlsx": "^0.18.5"
}
}

View File

@@ -0,0 +1,95 @@
import * as fs from 'node:fs';
const INPUT_CSV = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正.csv';
const OUTPUT_CSV = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
// 智能分类函数
function getCategory(project: string): string {
const desc = project.toLowerCase();
// 工资
if (desc.includes('工资') || desc.match(/amy|天天|碧桂园|皇|香缇卡|财务|客服|小哥|代理ip|sy|超鹏|小白/)) {
return '工资';
}
// 佣金/返佣
if (desc.includes('佣金') || desc.includes('返佣')) {
return '佣金/返佣';
}
// 分红
if (desc.includes('分红') || desc.includes('散户')) {
return '分红';
}
// 服务器/技术
if (desc.match(/服务器|技术|chatgpt|openai|ai|接口|ip|nat|宝塔|cdn|oss|google|翻译|openrouter|deepseek|claude|cursor|bolt|硅基|chatwoot/)) {
return '服务器/技术';
}
// 广告推广
if (desc.match(/广告|推广|地推|投放|打流量/)) {
return '广告推广';
}
// 软件/工具
if (desc.match(/会员|007|u盘|processon|飞机|虚拟卡|小红卡|信用卡|cloudflare|uizard|esim/)) {
return '软件/工具';
}
// 固定资产
if (desc.match(/买车|电脑|笔记本|显示器|rog|硬盘|服务器.*购买|iphone|路由器|展示屏/)) {
return '固定资产';
}
// 退款
if (desc.includes('退款') || desc.includes('退费') || desc.includes('退')) {
return '退款';
}
// 借款/转账
if (desc.match(/借|转给|龙腾|投资款|换.*铢|换美金|换现金|报销|房租|生活费|办公室|出差|接待|保关|测试|开工红包/)) {
return '借款/转账';
}
// 其他支出
return '其他支出';
}
// 读取并处理CSV
const content = fs.readFileSync(INPUT_CSV, 'utf-8');
const lines = content.split('\n');
// 修改表头,添加"分类"列
const header = lines[0];
const newHeader = header.trimEnd() + ',分类\n';
// 处理每一行数据
const newLines = [newHeader];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) {
newLines.push(line);
continue;
}
const columns = line.split(',');
if (columns.length < 2) {
newLines.push(line);
continue;
}
const project = columns[1]?.trim() || '';
const category = getCategory(project);
// 添加分类列
const newLine = line.trimEnd() + ',' + category + '\n';
newLines.push(newLine);
}
// 写入新文件
fs.writeFileSync(OUTPUT_CSV, newLines.join(''));
console.log(`✓ 已生成带分类的CSV文件: ${OUTPUT_CSV}`);
console.log(`共处理 ${lines.length - 1} 条记录`);

View File

@@ -0,0 +1,224 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
const CSV_FILE = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
const API_URL = 'http://localhost:3000/api/finance/transactions';
interface CSVRow {
date: string;
project: string;
type: string;
amount: string;
payer: string;
account: string;
adeShare: string;
memo: string;
category: string;
}
// 解析CSV文件
function parseCSV(content: string): CSVRow[] {
const lines = content.split('\n').slice(1); // 跳过表头
const rows: CSVRow[] = [];
for (const line of lines) {
if (!line.trim()) continue;
const columns = line.split(',');
if (columns.length < 6) continue;
rows.push({
date: columns[0]?.trim() || '',
project: columns[1]?.trim() || '',
type: columns[2]?.trim() || '',
amount: columns[3]?.trim() || '',
payer: columns[4]?.trim() || '',
account: columns[5]?.trim() || '',
adeShare: columns[6]?.trim() || '',
memo: columns[7]?.trim() || '',
category: columns[9]?.trim() || '', // 分类在第10列索引9
});
}
return rows;
}
// 转换日期格式 - 根据CSV顺序判断年份
// CSV顺序: 2024年8-12月 -> 2025年2-7月 -> 2025年8-10月
function parseDate(dateStr: string, previousDate: string = ''): string {
// 提取月日
const match = dateStr.match(/(\d+)月(\d+)日?/);
if (match) {
const month = Number.parseInt(match[1]);
const day = match[2].padStart(2, '0');
// 根据上一个日期和当前月份判断年份
let year = 2024;
if (previousDate) {
const prevYear = Number.parseInt(previousDate.split('-')[0]);
const prevMonth = Number.parseInt(previousDate.split('-')[1]);
// 如果月份从大变小例如12月->2月或7月->8月说明跨年了
if (month < prevMonth) {
year = prevYear + 1;
} else {
year = prevYear;
}
} else if (month >= 8) {
// 第一条记录8-12月是2024年
year = 2024;
} else {
// 第一条记录1-7月是2025年
year = 2025;
}
return `${year}-${String(month).padStart(2, '0')}-${day}`;
}
// 如果只有月份
const monthMatch = dateStr.match(/(\d+)月/);
if (monthMatch) {
const month = Number.parseInt(monthMatch[1]);
let year = 2024;
if (previousDate) {
const prevYear = Number.parseInt(previousDate.split('-')[0]);
const prevMonth = Number.parseInt(previousDate.split('-')[1]);
if (month < prevMonth) {
year = prevYear + 1;
} else {
year = prevYear;
}
} else if (month >= 8) {
year = 2024;
} else {
year = 2025;
}
return `${year}-${String(month).padStart(2, '0')}-01`;
}
// 使用上一条的日期
return previousDate || '2024-08-01';
}
// 解析金额,支持加法和乘法表达式
function parseAmount(amountStr: string): number {
// 移除空格
const cleaned = amountStr.trim();
// 如果包含乘号(*或×或x先处理乘法
if (cleaned.match(/[*×x]/)) {
// 提取乘法表达式,如 "200*3=600" 或 "200*3"
const mulMatch = cleaned.match(/(\d+(?:\.\d+)?)\s*[*×x]\s*(\d+(?:\.\d+)?)/);
if (mulMatch) {
const num1 = parseFloat(mulMatch[1]);
const num2 = parseFloat(mulMatch[2]);
if (!isNaN(num1) && !isNaN(num2)) {
return num1 * num2;
}
}
}
// 如果包含加号,计算总和
if (cleaned.includes('+')) {
const parts = cleaned.split('+');
let sum = 0;
for (const part of parts) {
const num = parseFloat(part.replace(/[^\d.]/g, ''));
if (!isNaN(num)) {
sum += num;
}
}
return sum;
}
// 否则直接解析
return parseFloat(cleaned.replace(/[^\d.]/g, '')) || 0;
}
// 根据分类名称获取分类ID
function getCategoryIdByName(categoryName: string): number {
const categoryMap: Record<string, number> = {
'工资': 5,
'佣金/返佣': 6,
'分红': 7,
'服务器/技术': 8,
'广告推广': 9,
'软件/工具': 10,
'固定资产': 11,
'退款': 12,
'借款/转账': 13,
'其他支出': 14,
};
return categoryMap[categoryName] || 2; // 默认未分类支出
}
// 批量导入
async function importTransactions() {
const content = fs.readFileSync(CSV_FILE, 'utf-8');
const rows = parseCSV(content);
console.log(`共解析到 ${rows.length} 条记录`);
let previousDate = '';
let imported = 0;
let failed = 0;
for (const row of rows) {
try {
const transactionDate = parseDate(row.date, previousDate);
if (transactionDate) {
previousDate = transactionDate;
}
const amount = parseAmount(row.amount);
if (amount <= 0) {
console.log(`跳过无效金额的记录: ${row.project} (金额: ${row.amount})`);
continue;
}
const transaction = {
type: 'expense', // CSV中都是支出
amount,
currency: 'USD', // 美金现金
transactionDate,
description: row.project || '无描述',
project: row.project,
memo: `支出人: ${row.payer || '未知'} | 账户: ${row.account || '未知'} | 备注: ${row.memo || '无'}`,
accountId: 1, // 默认使用美金现金账户 (id=1)
categoryId: getCategoryIdByName(row.category), // 使用CSV中的分类
};
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(transaction),
});
if (response.ok) {
imported++;
console.log(`✓ 导入成功 [${imported}/${rows.length}]: ${row.project} - $${amount}`);
} else {
failed++;
console.error(`✗ 导入失败: ${row.project}`, await response.text());
}
// 避免请求过快
await new Promise(resolve => setTimeout(resolve, 10));
} catch (error) {
failed++;
console.error(`✗ 处理失败: ${row.project}`, error);
}
}
console.log(`\n导入完成`);
console.log(`成功: ${imported}`);
console.log(`失败: ${failed}`);
}
importTransactions().catch(console.error);

View File

@@ -0,0 +1,281 @@
import { requestClient } from '../request';
export namespace FinanceApi {
// 货币类型
export interface Currency {
code: string;
name: string;
symbol: string;
isBase: boolean;
isActive: boolean;
}
// 分类
export interface Category {
id: number;
userId?: number | null;
name: string;
type: 'income' | 'expense';
icon: string;
color: string;
sortOrder?: number;
isSystem?: boolean;
isActive?: boolean;
}
// 账户
export interface Account {
id: number;
userId?: number;
name: string;
type: 'cash' | 'bank' | 'alipay' | 'wechat' | 'virtual_wallet' | 'investment' | 'credit_card';
currency: string;
balance?: number;
icon?: string;
color?: string;
isActive?: boolean;
}
// 汇率
export interface ExchangeRate {
id: number;
fromCurrency: string;
toCurrency: string;
rate: number;
date: string;
source: 'manual' | 'api' | 'system';
}
// 交易
export interface Transaction {
id: number;
userId: number;
type: 'income' | 'expense' | 'transfer';
amount: number;
currency: string;
exchangeRateToBase: number;
amountInBase: number;
categoryId?: number | null;
accountId?: number | null;
transactionDate: string;
description: string;
project?: string;
memo?: string;
createdAt: string;
isDeleted?: boolean;
deletedAt?: string;
}
// 创建交易的参数
export interface CreateTransactionParams {
type: 'income' | 'expense' | 'transfer';
amount: number;
currency: string;
categoryId?: number;
accountId?: number;
transactionDate: string;
description?: string;
project?: string;
memo?: string;
createdAt?: string;
}
// 预算
export interface Budget {
id: number;
userId: number;
category: string;
categoryId?: number;
emoji: string;
limit: number;
spent: number;
remaining: number;
percentage: number;
currency: string;
period: 'monthly' | 'weekly' | 'quarterly' | 'yearly';
alertThreshold: number;
description?: string;
autoRenew: boolean;
overspendAlert: boolean;
dailyReminder: boolean;
monthlyTrend?: number;
createdAt: string;
isDeleted?: boolean;
deletedAt?: string;
}
// 创建预算的参数
export interface CreateBudgetParams {
category: string;
categoryId?: number;
emoji: string;
limit: number;
spent?: number;
remaining?: number;
percentage?: number;
currency: string;
period: 'monthly' | 'weekly' | 'quarterly' | 'yearly';
alertThreshold: number;
description?: string;
autoRenew: boolean;
overspendAlert: boolean;
dailyReminder: boolean;
monthlyTrend?: number;
}
/**
* 获取所有货币
*/
export async function getCurrencies() {
return requestClient.get<Currency[]>('/finance/currencies');
}
/**
* 获取分类
*/
export async function getCategories(params?: { type?: 'income' | 'expense' | 'transfer' }) {
return requestClient.get<Category[]>('/finance/categories', { params });
}
/**
* 创建分类
*/
export async function createCategory(data: {
name: string;
type: 'income' | 'expense';
icon?: string;
color?: string;
}) {
return requestClient.post<Category | null>('/finance/categories', data);
}
/**
* 更新分类
*/
export async function updateCategory(
id: number,
data: {
name?: string;
icon?: string;
color?: string;
sortOrder?: number;
},
) {
return requestClient.put<Category | null>(`/finance/categories/${id}`, data);
}
/**
* 删除分类
*/
export async function deleteCategory(id: number) {
return requestClient.delete<{ message: string }>(
`/finance/categories/${id}`,
);
}
/**
* 获取账户
*/
export async function getAccounts(params?: { currency?: string }) {
return requestClient.get<Account[]>('/finance/accounts', { params });
}
/**
* 获取汇率
*/
export async function getExchangeRates(params?: {
from?: string;
to?: string;
date?: string;
}) {
return requestClient.get<ExchangeRate[]>('/finance/exchange-rates', {
params,
});
}
/**
* 获取交易列表
*/
export async function getTransactions(params?: {
type?: 'income' | 'expense' | 'transfer';
}) {
return requestClient.get<Transaction[]>('/finance/transactions', {
params,
});
}
/**
* 创建交易
*/
export async function createTransaction(data: CreateTransactionParams) {
return requestClient.post<Transaction>('/finance/transactions', data);
}
/**
* 更新交易
*/
export async function updateTransaction(
id: number,
data: Partial<CreateTransactionParams>,
) {
return requestClient.put<Transaction>(`/finance/transactions/${id}`, data);
}
/**
* 软删除交易
*/
export async function deleteTransaction(id: number) {
return requestClient.delete<{ message: string }>(
`/finance/transactions/${id}`,
);
}
/**
* 恢复交易
*/
export async function restoreTransaction(id: number) {
return requestClient.put<Transaction>(`/finance/transactions/${id}`, {
isDeleted: false,
});
}
/**
* 获取预算列表
*/
export async function getBudgets() {
return requestClient.get<Budget[]>('/finance/budgets');
}
/**
* 创建预算
*/
export async function createBudget(data: CreateBudgetParams) {
return requestClient.post<Budget>('/finance/budgets', data);
}
/**
* 更新预算
*/
export async function updateBudget(
id: number,
data: Partial<CreateBudgetParams>,
) {
return requestClient.put<Budget>(`/finance/budgets/${id}`, data);
}
/**
* 删除预算
*/
export async function deleteBudget(id: number) {
return requestClient.delete<{ message: string }>(`/finance/budgets/${id}`);
}
/**
* 恢复预算
*/
export async function restoreBudget(id: number) {
return requestClient.put<Budget>(`/finance/budgets/${id}`, {
isDeleted: false,
});
}
}

View File

@@ -95,7 +95,6 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => {
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
// 当前mock接口返回的错误字段是 error 或者 message
const responseData = error?.response?.data ?? {};
const errorMessage = responseData?.error ?? responseData?.message ?? '';
// 如果没有错误信息,则会根据状态码进行提示

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, onMounted, watch } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences';
@@ -28,6 +28,119 @@ const tokenTheme = computed(() => {
token: tokens,
};
});
// Function to flatten FinWise Pro menu
const flattenFinWiseProMenu = () => {
const submenus = document.querySelectorAll('.vben-sub-menu');
let finwiseMenu: Element | null = null;
submenus.forEach(menu => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl?.textContent?.includes('FinWise Pro')) {
finwiseMenu = menu;
}
});
if (!finwiseMenu) return;
const parentMenu = finwiseMenu.parentElement;
const childrenUL = finwiseMenu.querySelector('.vben-menu');
if (!childrenUL || !parentMenu) return;
// Check if already processed
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
// Move all children to the parent menu
const children = Array.from(childrenUL.children);
children.forEach(child => {
parentMenu.insertBefore(child, finwiseMenu);
});
// Mark for hiding via CSS and hide directly
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
(finwiseMenu as HTMLElement).style.display = 'none';
};
// Run on mount and watch for route changes
onMounted(() => {
// 强制修复sidebar设置防止被用户UI操作覆盖
const fixSidebarPreferences = () => {
const prefsKey = Object.keys(localStorage).find(k => k.includes('preferences') && !k.includes('locale') && !k.includes('theme'));
if (prefsKey) {
try {
const prefs = JSON.parse(localStorage.getItem(prefsKey) || '{}');
if (prefs.value?.sidebar) {
// 强制设置侧边栏为展开状态
prefs.value.sidebar.collapsed = false;
prefs.value.sidebar.expandOnHover = false;
prefs.value.sidebar.collapsedButton = false;
prefs.value.sidebar.collapsedWidth = 230;
localStorage.setItem(prefsKey, JSON.stringify(prefs));
}
} catch(e) {
console.error('Failed to fix sidebar preferences:', e);
}
}
};
// 立即执行一次
fixSidebarPreferences();
// Run multiple times with increasing delays to catch menu rendering
const delays = [100, 300, 500, 1000, 1500, 2000, 2500, 3000, 4000, 5000];
delays.forEach(delay => {
setTimeout(flattenFinWiseProMenu, delay);
});
// Watch for DOM changes (when menu is re-rendered)
const observer = new MutationObserver(() => {
setTimeout(flattenFinWiseProMenu, 200);
});
// Observe the body for menu changes
setTimeout(() => {
const body = document.body;
if (body) {
observer.observe(body, {
childList: true,
subtree: true
});
}
}, 100);
// 防止侧边栏自动收起
setTimeout(() => {
const preventSidebarCollapse = () => {
const sidebar = document.querySelector('[class*="sidebar"]') || document.querySelector('aside');
if (!sidebar) return;
// 创建MutationObserver监听class和style变化
const sidebarObserver = new MutationObserver(() => {
const currentWidth = window.getComputedStyle(sidebar).width;
// 如果宽度小于200px说明可能被收起了强制恢复
if (parseInt(currentWidth) < 200) {
(sidebar as HTMLElement).style.width = '230px';
}
});
// 开始观察
sidebarObserver.observe(sidebar, {
attributes: true,
attributeFilter: ['class', 'style']
});
// 强制设置初始宽度
(sidebar as HTMLElement).style.width = '230px';
};
// 延迟执行,等待侧边栏渲染
setTimeout(preventSidebarCollapse, 500);
setTimeout(preventSidebarCollapse, 1000);
setTimeout(preventSidebarCollapse, 2000);
}, 200);
});
</script>
<template>
@@ -37,3 +150,7 @@ const tokenTheme = computed(() => {
</App>
</ConfigProvider>
</template>
<style>
/* Styles can be added here if needed */
</style>

View File

@@ -0,0 +1,14 @@
/* Hide FinWise Pro parent menu and move children */
.vben-sub-menu:has(.vben-sub-menu-content__title:is(:contains("FinWise Pro"), :contains("💎"))) {
display: none !important;
}
/* Alternative approach using attribute selector if :contains doesn't work */
.vben-sub-menu .vben-sub-menu-content__title {
/* We'll use JavaScript to add a data attribute to the parent */
}
/* Mark submenu for hiding */
.vben-sub-menu[data-hide-finwise="true"] {
display: none !important;
}

View File

@@ -2,6 +2,7 @@ import { initPreferences } from '@vben/preferences';
import { unmountGlobalLoading } from '@vben/utils';
import { overridesPreferences } from './preferences';
import './custom.css';
/**
* 应用初始化完成之后再进行页面加载渲染
@@ -29,3 +30,68 @@ async function initApplication() {
}
initApplication();
// Flatten FinWise Pro menu globally
function flattenFinWiseProMenu() {
const submenus = document.querySelectorAll('.vben-sub-menu');
let finwiseMenu: Element | null = null;
submenus.forEach(menu => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl?.textContent?.includes('FinWise Pro')) {
finwiseMenu = menu;
}
});
if (!finwiseMenu) return;
const parentMenu = finwiseMenu.parentElement;
const childrenUL = finwiseMenu.querySelector('.vben-menu');
if (!childrenUL || !parentMenu) return;
// Check if already processed
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
// Move all children to the parent menu
const children = Array.from(childrenUL.children);
children.forEach(child => {
parentMenu.insertBefore(child, finwiseMenu);
});
// Mark for hiding via CSS and hide directly
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
(finwiseMenu as HTMLElement).style.display = 'none';
}
// Wait for DOM to be ready, then run the flatten function
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
// Run multiple times with delays to catch menu rendering
setTimeout(() => flattenFinWiseProMenu(), 500);
setTimeout(() => flattenFinWiseProMenu(), 1000);
setTimeout(() => flattenFinWiseProMenu(), 2000);
setTimeout(() => flattenFinWiseProMenu(), 3000);
});
} else {
// DOM is already loaded
setTimeout(() => flattenFinWiseProMenu(), 500);
setTimeout(() => flattenFinWiseProMenu(), 1000);
setTimeout(() => flattenFinWiseProMenu(), 2000);
setTimeout(() => flattenFinWiseProMenu(), 3000);
}
// Watch for DOM changes
setTimeout(() => {
const observer = new MutationObserver(() => {
setTimeout(flattenFinWiseProMenu, 100);
});
const body = document.body;
if (body) {
observer.observe(body, {
childList: true,
subtree: true
});
}
}, 500);

View File

@@ -11,5 +11,15 @@ export const overridesPreferences = defineOverridesPreferences({
name: 'Vben Admin Antd', // 固定网站名称,不随语言改变
locale: 'zh-CN', // 默认语言为中文
theme: 'dark', // 默认深色主题
defaultHomePath: '/dashboard-finance', // 默认首页改为财务仪表板
},
sidebar: {
collapsed: false, // 侧边栏默认展开
expandOnHover: false, // 禁用悬停展开
enable: true, // 启用侧边栏
width: 230, // 设置侧边栏宽度
collapsedWidth: 230, // 收起时的宽度也设为正常宽度,防止收起
extraCollapse: false, // 禁用额外的收起功能
collapsedButton: false, // 禁用折叠按钮
},
});

View File

@@ -34,4 +34,46 @@ const resetRoutes = () => resetStaticRoutes(router, routes);
// 创建路由守卫
createRouterGuard(router);
// Flatten FinWise Pro menu after each route change
router.afterEach(() => {
const flattenFinWiseProMenu = () => {
const submenus = document.querySelectorAll('.vben-sub-menu');
let finwiseMenu: Element | null = null;
submenus.forEach(menu => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl?.textContent?.includes('FinWise Pro')) {
finwiseMenu = menu;
}
});
if (!finwiseMenu) return;
const parentMenu = finwiseMenu.parentElement;
const childrenUL = finwiseMenu.querySelector('.vben-menu');
if (!childrenUL || !parentMenu) return;
// Check if already processed
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
// Move all children to the parent menu
const children = Array.from(childrenUL.children);
children.forEach(child => {
parentMenu.insertBefore(child, finwiseMenu);
});
// Mark for hiding via CSS and hide directly
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
(finwiseMenu as HTMLElement).style.display = 'none';
};
// Run multiple times to catch menu rendering
setTimeout(flattenFinWiseProMenu, 100);
setTimeout(flattenFinWiseProMenu, 300);
setTimeout(flattenFinWiseProMenu, 500);
setTimeout(flattenFinWiseProMenu, 1000);
setTimeout(flattenFinWiseProMenu, 2000);
});
export { resetRoutes, router };

View File

@@ -0,0 +1,106 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
name: 'FinanceDashboard',
path: '/dashboard-finance',
alias: ['/finance/dashboard'],
component: () => import('#/views/finance/dashboard/index.vue'),
meta: {
affixTab: true,
icon: 'mdi:chart-box',
order: 1,
title: '📊 财务仪表板',
},
},
{
name: 'FinanceTransactions',
path: '/transactions',
alias: ['/finance/transactions'],
component: () => import('#/views/finance/transactions/index.vue'),
meta: {
icon: 'mdi:swap-horizontal',
order: 2,
title: '💰 交易管理',
},
},
{
name: 'FinanceAccounts',
path: '/accounts',
alias: ['/finance/accounts'],
component: () => import('#/views/finance/accounts/index.vue'),
meta: {
icon: 'mdi:account-multiple',
order: 3,
title: '🏦 账户管理',
},
},
{
name: 'FinanceCategories',
path: '/categories',
alias: ['/finance/categories'],
component: () => import('#/views/finance/categories/index.vue'),
meta: {
icon: 'mdi:tag-multiple',
order: 4,
title: '🏷️ 分类管理',
},
},
{
name: 'FinanceBudgets',
path: '/budgets',
alias: ['/finance/budgets'],
component: () => import('#/views/finance/budgets/index.vue'),
meta: {
icon: 'mdi:target',
order: 5,
title: '🎯 预算管理',
},
},
{
name: 'FinanceStatistics',
path: '/statistics',
alias: ['/finance/statistics'],
component: () => import('#/views/finance/statistics/index.vue'),
meta: {
icon: 'mdi:chart-box-outline',
order: 6,
title: '📊 财务统计',
},
},
{
name: 'FinanceReports',
path: '/reports',
alias: ['/finance/reports'],
component: () => import('#/views/finance/reports/index.vue'),
meta: {
icon: 'mdi:chart-line',
order: 7,
title: '📈 报表分析',
},
},
{
name: 'FinanceTools',
path: '/tools',
alias: ['/finance/tools'],
component: () => import('#/views/finance/tools/index.vue'),
meta: {
icon: 'mdi:tools',
order: 8,
title: '🛠️ 财务工具',
},
},
{
name: 'FinanceSettings',
path: '/fin-settings',
alias: ['/finance/settings'],
component: () => import('#/views/finance/settings/index.vue'),
meta: {
icon: 'mdi:cog',
order: 9,
title: '⚙️ 系统设置',
},
},
];
export default routes;

View File

@@ -1,37 +1,10 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:layout-dashboard',
order: -1,
title: $t('page.dashboard.title'),
},
name: 'Dashboard',
path: '/dashboard',
children: [
{
name: 'Analytics',
path: '/analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
icon: 'lucide:area-chart',
title: $t('page.dashboard.analytics'),
},
},
{
name: 'Workspace',
path: '/workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',
title: $t('page.dashboard.workspace'),
},
},
],
name: 'Workspace',
path: '/workspace',
redirect: '/dashboard-finance',
},
];

View File

@@ -1,90 +0,0 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'mdi:bank',
order: 1,
title: '💎 FinWise Pro',
},
name: 'FinWisePro',
path: '/finance',
children: [
{
name: 'FinanceDashboard',
path: 'dashboard',
component: () => import('#/views/finance/dashboard/index.vue'),
meta: {
affixTab: true,
icon: 'mdi:chart-box',
title: '📊 财务仪表板',
},
},
{
name: 'TransactionManagement',
path: 'transactions',
component: () => import('#/views/finance/transactions/index.vue'),
meta: {
icon: 'mdi:swap-horizontal',
title: '💰 交易管理',
},
},
{
name: 'AccountManagement',
path: 'accounts',
component: () => import('#/views/finance/accounts/index.vue'),
meta: {
icon: 'mdi:account-multiple',
title: '🏦 账户管理',
},
},
{
name: 'CategoryManagement',
path: 'categories',
component: () => import('#/views/finance/categories/index.vue'),
meta: {
icon: 'mdi:tag-multiple',
title: '🏷️ 分类管理',
},
},
{
name: 'BudgetManagement',
path: 'budgets',
component: () => import('#/views/finance/budgets/index.vue'),
meta: {
icon: 'mdi:target',
title: '🎯 预算管理',
},
},
{
name: 'ReportsAnalytics',
path: 'reports',
component: () => import('#/views/finance/reports/index.vue'),
meta: {
icon: 'mdi:chart-line',
title: '📈 报表分析',
},
},
{
name: 'FinanceTools',
path: 'tools',
component: () => import('#/views/finance/tools/index.vue'),
meta: {
icon: 'mdi:tools',
title: '🛠️ 财务工具',
},
},
{
name: 'FinanceSettings',
path: 'settings',
component: () => import('#/views/finance/settings/index.vue'),
meta: {
icon: 'mdi:cog',
title: '⚙️ 系统设置',
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,325 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { FinanceApi } from '#/api/core/finance';
export const useFinanceStore = defineStore('finance', () => {
// 状态
const currencies = ref<FinanceApi.Currency[]>([]);
const incomeCategories = ref<FinanceApi.Category[]>([]);
const expenseCategories = ref<FinanceApi.Category[]>([]);
const accounts = ref<FinanceApi.Account[]>([]);
const exchangeRates = ref<FinanceApi.ExchangeRate[]>([]);
const transactions = ref<FinanceApi.Transaction[]>([]);
const budgets = ref<FinanceApi.Budget[]>([]);
// 加载状态
const loading = ref({
currencies: false,
categories: false,
accounts: false,
exchangeRates: false,
transactions: false,
budgets: false,
});
// 获取货币列表
async function fetchCurrencies() {
loading.value.currencies = true;
try {
currencies.value = await FinanceApi.getCurrencies();
} finally {
loading.value.currencies = false;
}
}
// 获取分类列表
async function fetchCategories() {
loading.value.categories = true;
try {
const [income, expense] = await Promise.all([
FinanceApi.getCategories({ type: 'income' }),
FinanceApi.getCategories({ type: 'expense' }),
]);
incomeCategories.value = income;
expenseCategories.value = expense;
} finally {
loading.value.categories = false;
}
}
// 创建分类
async function createCategory(data: {
name: string;
type: 'income' | 'expense';
icon?: string;
color?: string;
}) {
const category = await FinanceApi.createCategory(data);
if (!category) {
return null;
}
if (category.type === 'income') {
incomeCategories.value.push(category);
} else {
expenseCategories.value.push(category);
}
return category;
}
// 更新分类
async function updateCategory(
id: number,
data: {
name?: string;
icon?: string;
color?: string;
sortOrder?: number;
},
) {
const category = await FinanceApi.updateCategory(id, data);
if (!category) {
return null;
}
const list =
category.type === 'income'
? incomeCategories.value
: expenseCategories.value;
const index = list.findIndex((c) => c.id === id);
if (index !== -1) {
list[index] = category;
}
return category;
}
// 删除分类
async function deleteCategory(id: number) {
await FinanceApi.deleteCategory(id);
// 从本地列表中移除
let index = incomeCategories.value.findIndex((c) => c.id === id);
if (index !== -1) {
incomeCategories.value.splice(index, 1);
} else {
index = expenseCategories.value.findIndex((c) => c.id === id);
if (index !== -1) {
expenseCategories.value.splice(index, 1);
}
}
}
// 获取账户列表
async function fetchAccounts(currency?: string) {
loading.value.accounts = true;
try {
accounts.value = await FinanceApi.getAccounts(
currency ? { currency } : undefined,
);
} finally {
loading.value.accounts = false;
}
}
// 获取汇率
async function fetchExchangeRates() {
loading.value.exchangeRates = true;
try {
exchangeRates.value = await FinanceApi.getExchangeRates();
} finally {
loading.value.exchangeRates = false;
}
}
// 获取交易列表
async function fetchTransactions() {
loading.value.transactions = true;
try {
transactions.value = await FinanceApi.getTransactions();
} finally {
loading.value.transactions = false;
}
}
// 创建交易
async function createTransaction(data: FinanceApi.CreateTransactionParams) {
const transaction = await FinanceApi.createTransaction(data);
// 添加到本地列表
transactions.value.unshift(transaction);
// 重新获取账户余额
await fetchAccounts();
return transaction;
}
// 更新交易
async function updateTransaction(
id: number,
data: Partial<FinanceApi.CreateTransactionParams>,
) {
const transaction = await FinanceApi.updateTransaction(id, data);
// 更新本地列表
const index = transactions.value.findIndex((t) => t.id === id);
if (index !== -1) {
transactions.value[index] = transaction;
}
// 重新获取账户余额
await fetchAccounts();
return transaction;
}
// 软删除交易
async function softDeleteTransaction(id: number) {
await FinanceApi.deleteTransaction(id);
// 更新本地列表中的删除状态
const index = transactions.value.findIndex((t) => t.id === id);
if (index !== -1) {
transactions.value[index] = {
...transactions.value[index],
isDeleted: true,
deletedAt: new Date().toISOString(),
};
}
// 重新获取账户余额
await fetchAccounts();
}
// 恢复交易
async function restoreTransaction(id: number) {
const transaction = await FinanceApi.restoreTransaction(id);
// 更新本地列表
const index = transactions.value.findIndex((t) => t.id === id);
if (index !== -1) {
transactions.value[index] = transaction;
}
// 重新获取账户余额
await fetchAccounts();
return transaction;
}
// 根据货币代码获取货币信息
function getCurrencyByCode(code: string) {
return currencies.value.find((c) => c.code === code);
}
// 根据账户ID获取账户信息
function getAccountById(id: number) {
return accounts.value.find((a) => a.id === id);
}
// 根据分类ID获取分类信息
function getCategoryById(id: number) {
return [...incomeCategories.value, ...expenseCategories.value].find(
(c) => c.id === id,
);
}
// 获取汇率
function getExchangeRate(from: string, to: string) {
return exchangeRates.value.find(
(r) => r.fromCurrency === from && r.toCurrency === to,
);
}
// 根据货币过滤账户
function getAccountsByCurrency(currency: string) {
return accounts.value.filter((a) => a.currency === currency);
}
// 获取预算列表
async function fetchBudgets() {
loading.value.budgets = true;
try {
budgets.value = await FinanceApi.getBudgets();
} finally {
loading.value.budgets = false;
}
}
// 创建预算
async function createBudget(data: FinanceApi.CreateBudgetParams) {
const budget = await FinanceApi.createBudget(data);
budgets.value.push(budget);
return budget;
}
// 更新预算
async function updateBudget(
id: number,
data: Partial<FinanceApi.CreateBudgetParams>,
) {
const budget = await FinanceApi.updateBudget(id, data);
const index = budgets.value.findIndex((b) => b.id === id);
if (index !== -1) {
budgets.value[index] = budget;
}
return budget;
}
// 删除预算
async function deleteBudget(id: number) {
await FinanceApi.deleteBudget(id);
const index = budgets.value.findIndex((b) => b.id === id);
if (index !== -1) {
budgets.value[index] = {
...budgets.value[index],
isDeleted: true,
deletedAt: new Date().toISOString(),
};
}
}
// 恢复预算
async function restoreBudget(id: number) {
const budget = await FinanceApi.restoreBudget(id);
const index = budgets.value.findIndex((b) => b.id === id);
if (index !== -1) {
budgets.value[index] = budget;
}
return budget;
}
// 初始化所有数据
async function initializeData() {
await Promise.all([
fetchCurrencies(),
fetchCategories(),
fetchAccounts(),
fetchExchangeRates(),
]);
}
return {
// 状态
currencies,
incomeCategories,
expenseCategories,
accounts,
exchangeRates,
transactions,
budgets,
loading,
// 方法
fetchCurrencies,
fetchCategories,
createCategory,
updateCategory,
deleteCategory,
fetchAccounts,
fetchExchangeRates,
fetchTransactions,
createTransaction,
updateTransaction,
softDeleteTransaction,
restoreTransaction,
fetchBudgets,
createBudget,
updateBudget,
deleteBudget,
restoreBudget,
getCurrencyByCode,
getAccountById,
getCategoryById,
getExchangeRate,
getAccountsByCurrency,
initializeData,
};
});

View File

@@ -78,13 +78,13 @@ const formSchema = computed((): VbenFormSchema[] => {
label: $t('authentication.password'),
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
},
{
component: markRaw(SliderCaptcha),
fieldName: 'captcha',
rules: z.boolean().refine((value) => value, {
message: $t('authentication.verifyRequiredTip'),
}),
},
// {
// component: markRaw(SliderCaptcha),
// fieldName: 'captcha',
// rules: z.boolean().refine((value) => value, {
// message: $t('authentication.verifyRequiredTip'),
// }),
// },
];
});
</script>

View File

@@ -1,9 +1,25 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
import { useRouter } from 'vue-router';
defineOptions({ name: 'Fallback404Demo' });
const router = useRouter();
function handleBackHome() {
router.push('/dashboard-finance');
}
</script>
<template>
<Fallback status="404" />
<div class="flex h-screen w-screen flex-col items-center justify-center bg-background">
<h1 class="mb-4 text-6xl font-bold text-foreground">404</h1>
<h2 class="mb-2 text-2xl font-semibold text-foreground">哎呀未找到页面</h2>
<p class="mb-8 text-muted-foreground">抱歉我们无法找到您要找的页面</p>
<button
@click="handleBackHome"
class="rounded-md bg-primary px-6 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
>
返回首页
</button>
</div>
</template>

View File

@@ -6,7 +6,7 @@ import type {
WorkbenchTrendItem,
} from '@vben/common-ui';
import { ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
@@ -21,208 +21,481 @@ import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import { Modal, Form, Input, Select, DatePicker, InputNumber, message, Radio, Space, Button, Row, Col, Switch } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useFinanceStore } from '#/store/finance';
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
const userStore = useUserStore();
const financeStore = useFinanceStore();
// 这是一个示例数据,实际项目中需要根据实际情况进行调整
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转
// 例如url: /dashboard/workspace
// 初始化财务数据
onMounted(async () => {
await financeStore.initializeData();
});
// 快速记账弹窗
const quickAddVisible = ref(false);
const transactionType = ref<'income' | 'expense'>('expense');
const formRef = ref();
const formState = ref({
currency: 'CNY', // 默认人民币
quantity: 1, // 数量默认1
unitPrice: null, // 单价
amount: null, // 总金额(自动计算或手动输入)
weight: null, // 重量(可选)
weightUnit: 'kg', // 重量单位,默认千克
category: undefined,
account: undefined,
date: null,
description: '',
});
// 是否使用单价×数量计算模式
const useQuantityMode = ref(false);
// 当前选中的日期类型
const selectedDateType = ref<'today' | 'yesterday' | 'week' | 'month' | 'custom'>('today');
// 字段触摸状态(用于判断是否显示验证提示)
const touchedFields = ref({
category: false,
account: false,
amount: false,
});
// 监听单价和数量变化,自动计算总金额
watch([() => formState.value.unitPrice, () => formState.value.quantity], ([unitPrice, quantity]) => {
if (useQuantityMode.value && unitPrice && quantity) {
formState.value.amount = unitPrice * quantity;
}
});
// 切换计算模式
const toggleQuantityMode = (enabled: boolean) => {
useQuantityMode.value = enabled;
if (enabled) {
// 如果当前有金额,反推单价
if (formState.value.amount && formState.value.quantity) {
formState.value.unitPrice = formState.value.amount / formState.value.quantity;
}
} else {
// 关闭模式时清空单价和数量
formState.value.quantity = 1;
formState.value.unitPrice = null;
}
};
// 计算属性: 当前分类列表
const currentCategories = computed(() => {
return transactionType.value === 'income'
? financeStore.incomeCategories
: financeStore.expenseCategories;
});
// 计算属性: 根据选择的货币过滤账户
const filteredAccounts = computed(() => {
return financeStore.getAccountsByCurrency(formState.value.currency);
});
// 计算属性: 获取当前货币符号
const currentCurrencySymbol = computed(() => {
const currency = financeStore.getCurrencyByCode(formState.value.currency);
return currency?.symbol || '¥';
});
// 监听货币变化,重置账户选择
watch(() => formState.value.currency, () => {
formState.value.account = undefined;
touchedFields.value.account = true; // 标记账户字段为已触摸
});
// 监听账户变化保存到localStorage
watch(() => formState.value.account, (newAccountId) => {
if (newAccountId && transactionType.value) {
const storageKey = transactionType.value === 'income'
? 'lastWorkspaceIncomeAccountId'
: 'lastWorkspaceExpenseAccountId';
localStorage.setItem(storageKey, String(newAccountId));
}
});
// 打开快速记账弹窗
const openQuickAdd = (type: 'income' | 'expense') => {
transactionType.value = type;
quickAddVisible.value = true;
// 读取上次选择的账户
const storageKey = type === 'income'
? 'lastWorkspaceIncomeAccountId'
: 'lastWorkspaceExpenseAccountId';
const lastAccountId = localStorage.getItem(storageKey);
const accountId = lastAccountId ? Number(lastAccountId) : undefined;
// 重置表单,日期默认为今天,货币默认为CNY
formState.value = {
currency: 'CNY',
quantity: 1,
unitPrice: null,
amount: null,
weight: null,
weightUnit: 'kg',
category: undefined,
account: accountId,
date: dayjs(),
description: '',
};
// 重置计算模式
useQuantityMode.value = false;
// 重置触摸状态
touchedFields.value = {
category: false,
account: false,
amount: false,
};
};
// 日期快捷方式
const setDate = (type: 'today' | 'yesterday' | 'week' | 'month') => {
selectedDateType.value = type;
switch (type) {
case 'today':
formState.value.date = dayjs();
break;
case 'yesterday':
formState.value.date = dayjs().subtract(1, 'day');
break;
case 'week':
formState.value.date = dayjs().startOf('week');
break;
case 'month':
formState.value.date = dayjs().startOf('month');
break;
}
};
// 监听日期手动变化,设置为自定义
watch(() => formState.value.date, (newDate) => {
if (!newDate) return;
const today = dayjs();
const yesterday = dayjs().subtract(1, 'day');
const weekStart = dayjs().startOf('week');
const monthStart = dayjs().startOf('month');
if (newDate.isSame(today, 'day')) {
selectedDateType.value = 'today';
} else if (newDate.isSame(yesterday, 'day')) {
selectedDateType.value = 'yesterday';
} else if (newDate.isSame(weekStart, 'day')) {
selectedDateType.value = 'week';
} else if (newDate.isSame(monthStart, 'day')) {
selectedDateType.value = 'month';
} else {
selectedDateType.value = 'custom';
}
});
// 获取日期类型对应的颜色
const getDateTypeColor = (type: string) => {
const colors = {
today: '#52c41a', // 绿色 - 今天
yesterday: '#1890ff', // 蓝色 - 昨天
week: '#722ed1', // 紫色 - 本周
month: '#fa8c16', // 橙色 - 本月
custom: '#8c8c8c', // 灰色 - 自定义
};
return colors[type] || colors.custom;
};
// 计算属性:检查必填字段是否有错误
const fieldErrors = computed(() => ({
category: touchedFields.value.category && !formState.value.category,
account: touchedFields.value.account && !formState.value.account,
amount: touchedFields.value.amount && (!formState.value.amount || formState.value.amount <= 0),
}));
// 提交记账
const handleQuickAdd = async () => {
try {
// 标记所有必填字段为已触摸,以便显示验证错误
touchedFields.value = {
category: true,
account: true,
amount: true,
};
await formRef.value?.validate();
console.log('开始创建交易,表单数据:', formState.value);
console.log('交易类型:', transactionType.value);
// 调用API创建交易
const transaction = await financeStore.createTransaction({
type: transactionType.value,
amount: formState.value.amount!,
currency: formState.value.currency,
categoryId: formState.value.category || undefined,
accountId: formState.value.account!,
transactionDate: formState.value.date.format('YYYY-MM-DD'),
description: formState.value.description,
});
console.log('交易创建成功:', transaction);
message.success(`${transactionType.value === 'income' ? '收入' : '支出'}记录成功!`);
quickAddVisible.value = false;
// 重置表单
formState.value = {
currency: 'CNY',
quantity: 1,
unitPrice: null,
amount: null,
weight: null,
weightUnit: 'kg',
category: undefined,
account: undefined,
date: null,
description: '',
};
// 重置计算模式
useQuantityMode.value = false;
// 重置触摸状态
touchedFields.value = {
category: false,
account: false,
amount: false,
};
} catch (error) {
console.error('创建交易失败:', error);
console.error('错误详情:', JSON.stringify(error, null, 2));
if (error?.errorFields) {
message.error('❌ 请填写所有必填项!');
} else {
message.error(`创建交易失败: ${error.message || '未知错误'}`);
}
}
};
// 财务管理快捷项目
const projectItems: WorkbenchProjectItem[] = [
{
color: '',
content: '不要等待机会,而要创造机会。',
date: '2021-04-01',
group: '开源组',
icon: 'carbon:logo-github',
title: 'Github',
url: 'https://github.com',
color: '#1890ff',
content: '查看本月收支情况和财务概览',
date: new Date().toLocaleDateString(),
group: '财务管理',
icon: 'mdi:chart-box',
title: '财务仪表板',
url: '/dashboard-finance',
},
{
color: '#3fb27f',
content: '现在的你决定将来的你。',
date: '2021-04-01',
group: '算法组',
icon: 'ion:logo-vue',
title: 'Vue',
url: 'https://vuejs.org',
color: '#52c41a',
content: '记录和管理所有收入支出交易',
date: new Date().toLocaleDateString(),
group: '财务管理',
icon: 'mdi:swap-horizontal',
title: '交易管理',
url: '/transactions',
},
{
color: '#e18525',
content: '没有什么才能比努力更重要。',
date: '2021-04-01',
group: '上班摸鱼',
icon: 'ion:logo-html5',
title: 'Html5',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML',
color: '#faad14',
content: '管理银行账户、信用卡等资产',
date: new Date().toLocaleDateString(),
group: '财务管理',
icon: 'mdi:account-multiple',
title: '账户管理',
url: '/accounts',
},
{
color: '#bf0c2c',
content: '热情和欲望可以突破一切难关。',
date: '2021-04-01',
group: 'UI',
icon: 'ion:logo-angular',
title: 'Angular',
url: 'https://angular.io',
color: '#722ed1',
content: '查看和分析各类财务报表',
date: new Date().toLocaleDateString(),
group: '数据分析',
icon: 'mdi:chart-line',
title: '报表分析',
url: '/reports',
},
{
color: '#00d8ff',
content: '健康的身体是实现目标的基石。',
date: '2021-04-01',
group: '技术牛',
icon: 'bx:bxl-react',
title: 'React',
url: 'https://reactjs.org',
color: '#eb2f96',
content: '设置和监控各项预算目标',
date: new Date().toLocaleDateString(),
group: '财务规划',
icon: 'mdi:target',
title: '预算管理',
url: '/budgets',
},
{
color: '#EBD94E',
content: '路是走出来的,而不是空想出来的。',
date: '2021-04-01',
group: '架构组',
icon: 'ion:logo-javascript',
title: 'Js',
url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript',
color: '#13c2c2',
content: '管理收支分类标签',
date: new Date().toLocaleDateString(),
group: '设置',
icon: 'mdi:tag-multiple',
title: '分类管理',
url: '/categories',
},
];
// 同样,这里的 url 也可以使用以 http 开头的外部链接
// 财务管理快捷导航
const quickNavItems: WorkbenchQuickNavItem[] = [
{
color: '#1fdaca',
icon: 'ion:home-outline',
title: '首页',
url: '/',
color: '#1890ff',
icon: 'mdi:chart-box',
title: '财务仪表板',
url: '/dashboard-finance',
},
{
color: '#bf0c2c',
icon: 'ion:grid-outline',
title: '仪表盘',
url: '/dashboard',
color: '#52c41a',
icon: 'mdi:cash-plus',
title: '添加收入',
url: 'quick-add-income', // 特殊标识,用于触发弹窗
},
{
color: '#e18525',
icon: 'ion:layers-outline',
title: '组件',
url: '/demos/features/icons',
color: '#f5222d',
icon: 'mdi:cash-minus',
title: '添加支出',
url: 'quick-add-expense', // 特殊标识,用于触发弹窗
},
{
color: '#3fb27f',
icon: 'ion:settings-outline',
title: '系统管理',
url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整
color: '#faad14',
icon: 'mdi:bank',
title: '账户总览',
url: '/accounts',
},
{
color: '#4daf1bc9',
icon: 'ion:key-outline',
title: '权限管理',
url: '/demos/access/page-control',
color: '#722ed1',
icon: 'mdi:chart-line',
title: '财务报表',
url: '/reports',
},
{
color: '#00d8ff',
icon: 'ion:bar-chart-outline',
title: '图表',
url: '/analytics',
color: '#13c2c2',
icon: 'mdi:cog',
title: '系统设置',
url: '/fin-settings',
},
];
const todoItems = ref<WorkbenchTodoItem[]>([
{
completed: false,
content: `审查最近提交到Git仓库的前端代码确保代码质量和规范。`,
date: '2024-07-30 11:00:00',
title: '审查前端代码提交',
content: `记录本月的水电费、房租等固定支出`,
date: new Date().toLocaleDateString() + ' 18:00:00',
title: '录入本月固定支出',
},
{
completed: false,
content: `查看并调整各类别的预算设置,确保支出在可控范围内`,
date: new Date().toLocaleDateString() + ' 20:00:00',
title: '检查月度预算执行情况',
},
{
completed: true,
content: `检查并优化系统性能降低CPU使用率。`,
date: '2024-07-30 11:00:00',
title: '系统性能优化',
content: `完成本周的收入记录,包括工资和其他收入来源`,
date: new Date().toLocaleDateString() + ' 10:00:00',
title: '记录本周收入',
},
{
completed: false,
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
date: '2024-07-30 11:00:00',
title: '安全检查',
content: `核对银行账户余额,确保系统数据与实际一致`,
date: new Date().toLocaleDateString() + ' 15:00:00',
title: '对账核对',
},
{
completed: false,
content: `更新项目中的所有npm依赖包确保使用最新版本。`,
date: '2024-07-30 11:00:00',
title: '更新项目依赖',
},
{
completed: false,
content: `修复用户报告的页面UI显示问题确保在不同浏览器中显示一致。 `,
date: '2024-07-30 11:00:00',
title: '修复UI显示问题',
content: `分析上月的支出报表,找出可以节省开支的地方`,
date: new Date().toLocaleDateString() + ' 16:00:00',
title: '生成月度财务报表',
},
]);
const trendItems: WorkbenchTrendItem[] = [
{
avatar: 'svg:avatar-1',
content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
content: `添加了一笔 <a>餐饮支出</a> ¥128.50`,
date: '刚刚',
title: '威廉',
title: '系统记录',
},
{
avatar: 'svg:avatar-2',
content: `关注了 <a>威廉</a> `,
date: '1个小时前',
title: '艾文',
content: `记录了 <a>工资收入</a> ¥12,000.00`,
date: '2小时前',
title: '收入记录',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
date: '1天前',
title: '克里斯',
content: `更新了 <a>餐饮类别</a> 的预算额度`,
date: '今天 14:30',
title: '预算调整',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写一个Vite插件</a> `,
date: '2天前',
title: 'Vben',
content: `创建了新的 <a>信用卡账户</a> `,
date: '今天 10:15',
title: '账户管理',
},
{
avatar: 'svg:avatar-1',
content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
date: '3天前',
title: '皮特',
content: `生成了 <a>月度财务报表</a>`,
date: '昨天',
title: '报表生成',
},
{
avatar: 'svg:avatar-2',
content: `关闭了问题 <a>如何运行项目</a> `,
date: '1周前',
title: '杰克',
content: `完成了 <a>账户对账</a> 操作`,
date: '昨天',
title: '对账记录',
},
{
avatar: 'svg:avatar-3',
content: `发布了 <a>个人动态</a> `,
content: `添加了 <a>房租支出</a> ¥3,500.00`,
date: '2天前',
title: '支出记录',
},
{
avatar: 'svg:avatar-4',
content: `设置了 <a>月度预算目标</a>`,
date: '3天前',
title: '预算规划',
},
{
avatar: 'svg:avatar-1',
content: `优化了 <a>支出分类</a> 设置`,
date: '1周前',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `推送了代码到 <a>Github</a>`,
date: '2021-04-01 20:00',
title: '威廉',
},
{
avatar: 'svg:avatar-4',
content: `发表文章 <a>如何编写使用 Admin Vben</a> `,
date: '2021-03-01 20:00',
title: 'Vben',
title: '分类管理',
},
];
const router = useRouter();
// 这是一个示例方法,实际项目中需要根据实际情况进行调整
// This is a sample method, adjust according to the actual project requirements
// 导航处理方法
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
console.log('navTo被调用:', nav);
console.log('nav.url:', nav.url);
// 处理快速记账
if (nav.url === 'quick-add-income') {
console.log('打开收入弹窗');
openQuickAdd('income');
return;
}
if (nav.url === 'quick-add-expense') {
console.log('打开支出弹窗');
openQuickAdd('expense');
return;
}
// 处理外部链接
if (nav.url?.startsWith('http')) {
openWindow(nav.url);
return;
}
// 处理内部路由
if (nav.url?.startsWith('/')) {
router.push(nav.url).catch((error) => {
console.error('Navigation failed:', error);
@@ -239,28 +512,344 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
>
<template #title>
早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧
欢迎回来, {{ userStore.userInfo?.realName }}开始管理您的财务吧 💰
</template>
<template #description>
让每一笔收支都清晰可见让财务管理更轻松
</template>
<template #description> 今日晴20 - 32 </template>
</WorkbenchHeader>
<div class="mt-5 flex flex-col lg:flex-row">
<div class="mr-4 w-full lg:w-3/5">
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
<WorkbenchProject :items="projectItems" title="财务功能快捷入口" @click="navTo" />
<WorkbenchTrends :items="trendItems" class="mt-5" title="最近财务活动" />
</div>
<div class="w-full lg:w-2/5">
<WorkbenchQuickNav
:items="quickNavItems"
class="mt-5 lg:mt-0"
title="快捷导航"
@click="navTo"
title="快捷操作"
@click="(item) => {
console.log('WorkbenchQuickNav click事件触发:', item);
navTo(item);
}"
/>
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办事项" />
<AnalysisChartCard class="mt-5" title="访问来源">
<WorkbenchTodo :items="todoItems" class="mt-5" title="财务待办事项" />
<AnalysisChartCard class="mt-5" title="本月收支概览">
<AnalyticsVisitsSource />
</AnalysisChartCard>
</div>
</div>
<!-- 快速记账弹窗 -->
<Modal
:open="quickAddVisible"
:title="transactionType === 'income' ? '💰 添加收入' : '💸 添加支出'"
:width="900"
@ok="handleQuickAdd"
@cancel="() => { quickAddVisible = false; }"
@update:open="(val) => { quickAddVisible = val; }"
>
<Form
ref="formRef"
:model="formState"
layout="vertical"
class="mt-4"
>
<Row :gutter="16">
<!-- 分类 -->
<Col :span="14">
<Form.Item
label="分类"
name="category"
:rules="[{ required: true, message: '请选择分类' }]"
:validate-status="fieldErrors.category ? 'error' : ''"
:help="fieldErrors.category ? '⚠️ 请选择一个分类' : ''"
>
<div
:style="fieldErrors.category ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '8px' } : {}"
>
<Radio.Group
v-model:value="formState.category"
size="large"
button-style="solid"
class="category-radio-group"
@change="touchedFields.category = true"
>
<Radio.Button
v-for="category in currentCategories"
:key="category.id"
:value="category.id"
>
{{ category.icon }} {{ category.name }}
</Radio.Button>
</Radio.Group>
</div>
</Form.Item>
</Col>
<!-- 项目名称 -->
<Col :span="10">
<Form.Item
label="项目名称"
name="description"
>
<Input.TextArea
v-model:value="formState.description"
placeholder="请输入项目名称..."
:rows="4"
style="height: 100%"
/>
</Form.Item>
</Col>
</Row>
<!-- 货币类型账户和金额放在一起 -->
<div class="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-4">
<Row :gutter="16">
<Col :span="12">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">货币类型 <span class="text-red-500">*</span></label>
<Radio.Group
v-model:value="formState.currency"
size="large"
button-style="solid"
class="currency-radio-group"
>
<Radio.Button
v-for="currency in financeStore.currencies"
:key="currency.code"
:value="currency.code"
>
{{ currency.symbol }} {{ currency.name }}
</Radio.Button>
</Radio.Group>
</div>
</Col>
<Col :span="12">
<div class="mb-4 flex items-center justify-between">
<label class="text-sm font-medium">按数量×单价计算</label>
<Switch v-model:checked="useQuantityMode" @change="toggleQuantityMode" />
</div>
</Col>
</Row>
<!-- 数量×单价模式 -->
<Row v-if="useQuantityMode" :gutter="16" class="mb-4">
<Col :span="8">
<label class="block text-sm font-medium mb-2">数量</label>
<InputNumber
v-model:value="formState.quantity"
:min="0.01"
:precision="2"
placeholder="数量"
style="width: 100%"
size="large"
/>
</Col>
<Col :span="8">
<label class="block text-sm font-medium mb-2">单价</label>
<InputNumber
v-model:value="formState.unitPrice"
:min="0"
:precision="2"
placeholder="单价"
style="width: 100%"
size="large"
>
<template #addonBefore>{{ currentCurrencySymbol }}</template>
</InputNumber>
</Col>
<Col :span="8">
<label class="block text-sm font-medium mb-2">
总金额 <span class="text-red-500">*</span>
<span v-if="fieldErrors.amount" class="text-red-500 text-xs ml-1"></span>
</label>
<div
:style="fieldErrors.amount ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '2px' } : {}"
>
<InputNumber
v-model:value="formState.amount"
:min="0"
:precision="2"
placeholder="自动计算"
style="width: 100%"
size="large"
:disabled="true"
@blur="touchedFields.amount = true"
>
<template #addonBefore>{{ currentCurrencySymbol }}</template>
</InputNumber>
</div>
</Col>
</Row>
<!-- 直接输入金额模式 -->
<Row v-else :gutter="16" class="mb-4">
<Col :span="24">
<label class="block text-sm font-medium mb-2">
金额 <span class="text-red-500">*</span>
<span v-if="fieldErrors.amount" class="text-red-500 text-xs ml-2"> 请输入金额</span>
</label>
<div
:style="fieldErrors.amount ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '2px' } : {}"
>
<InputNumber
v-model:value="formState.amount"
:min="0"
:precision="2"
placeholder="请输入金额"
style="width: 100%"
size="large"
@blur="touchedFields.amount = true"
>
<template #addonBefore>{{ currentCurrencySymbol }}</template>
</InputNumber>
</div>
</Col>
</Row>
<!-- 重量可选 -->
<Row :gutter="16" class="mb-4">
<Col :span="16">
<label class="block text-sm font-medium mb-2">重量可选</label>
<InputNumber
v-model:value="formState.weight"
:min="0"
:precision="3"
placeholder="如需记录重量请输入"
style="width: 100%"
/>
</Col>
<Col :span="8">
<label class="block text-sm font-medium mb-2">单位</label>
<Select v-model:value="formState.weightUnit" style="width: 100%">
<Select.Option value="kg">千克(kg)</Select.Option>
<Select.Option value="g">(g)</Select.Option>
<Select.Option value="t">(t)</Select.Option>
<Select.Option value="lb">(lb)</Select.Option>
</Select>
</Col>
</Row>
<div>
<label class="block text-sm font-medium mb-2">
{{ transactionType === 'income' ? '收入账户' : '支出账户' }} <span class="text-red-500">*</span>
<span v-if="fieldErrors.account" class="text-red-500 text-xs ml-2"> 请选择账户</span>
</label>
<div
:style="fieldErrors.account ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '8px' } : {}"
>
<Radio.Group
v-model:value="formState.account"
size="large"
button-style="solid"
class="account-radio-group"
@change="touchedFields.account = true"
>
<Radio.Button
v-for="account in filteredAccounts"
:key="account.id"
:value="account.id"
>
{{ account.icon }} {{ account.name }}
</Radio.Button>
</Radio.Group>
</div>
</div>
</div>
<!-- 日期 -->
<Row :gutter="16">
<Col :span="10">
<Form.Item label="日期快捷选择">
<div class="flex flex-col space-y-2">
<Button
:type="selectedDateType === 'today' ? 'primary' : 'default'"
:style="{ backgroundColor: selectedDateType === 'today' ? getDateTypeColor('today') : undefined, borderColor: selectedDateType === 'today' ? getDateTypeColor('today') : undefined }"
@click="setDate('today')"
block
>
今天
</Button>
<Button
:type="selectedDateType === 'yesterday' ? 'primary' : 'default'"
:style="{ backgroundColor: selectedDateType === 'yesterday' ? getDateTypeColor('yesterday') : undefined, borderColor: selectedDateType === 'yesterday' ? getDateTypeColor('yesterday') : undefined }"
@click="setDate('yesterday')"
block
>
昨天
</Button>
<Button
:type="selectedDateType === 'week' ? 'primary' : 'default'"
:style="{ backgroundColor: selectedDateType === 'week' ? getDateTypeColor('week') : undefined, borderColor: selectedDateType === 'week' ? getDateTypeColor('week') : undefined }"
@click="setDate('week')"
block
>
本周
</Button>
<Button
:type="selectedDateType === 'month' ? 'primary' : 'default'"
:style="{ backgroundColor: selectedDateType === 'month' ? getDateTypeColor('month') : undefined, borderColor: selectedDateType === 'month' ? getDateTypeColor('month') : undefined }"
@click="setDate('month')"
block
>
本月
</Button>
</div>
</Form.Item>
</Col>
<Col :span="14">
<Form.Item
label="选择日期"
name="date"
>
<div
class="date-picker-wrapper"
:style="{
border: `2px solid ${getDateTypeColor(selectedDateType)}`,
borderRadius: '6px',
padding: '4px'
}"
>
<DatePicker
v-model:value="formState.date"
placeholder="请选择日期"
style="width: 100%"
size="large"
format="YYYY-MM-DD"
/>
</div>
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
</div>
</template>
<style scoped>
/* 分类、货币和账户按钮组允许换行 */
:deep(.category-radio-group),
:deep(.currency-radio-group),
:deep(.account-radio-group) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
:deep(.category-radio-group .ant-radio-button-wrapper),
:deep(.currency-radio-group .ant-radio-button-wrapper),
:deep(.account-radio-group .ant-radio-button-wrapper) {
margin-right: 0 !important;
margin-bottom: 0 !important;
border-radius: 6px !important;
}
:deep(.category-radio-group .ant-radio-button-wrapper:not(:first-child)::before),
:deep(.currency-radio-group .ant-radio-button-wrapper:not(:first-child)::before),
:deep(.account-radio-group .ant-radio-button-wrapper:not(:first-child)::before) {
display: none;
}
</style>

View File

@@ -51,7 +51,7 @@
<Card v-for="account in accounts" :key="account.id" class="hover:shadow-lg transition-shadow">
<template #title>
<div class="flex items-center space-x-2">
<span class="text-xl">{{ account.emoji }}</span>
<span class="text-xl">{{ account.icon }}</span>
<span>{{ account.name }}</span>
</div>
</template>
@@ -66,17 +66,16 @@
<Button type="text" size="small"></Button>
</Dropdown>
</template>
<div class="space-y-4">
<div class="text-center">
<p class="text-2xl font-bold" :class="account.balance >= 0 ? 'text-green-600' : 'text-red-600'">
{{ account.balance.toLocaleString() }} {{ account.currency || 'CNY' }}
{{ getCurrencySymbol(account.currency) }}{{ account.balance.toLocaleString() }}
</p>
<p class="text-sm text-gray-500">{{ account.type }}</p>
<p v-if="account.bank" class="text-xs text-gray-400">{{ account.bank }}</p>
<p v-if="account.currency && account.currency !== 'CNY'" class="text-xs text-blue-500">{{ account.currency }}</p>
<p class="text-sm text-gray-500">{{ getAccountTypeText(account.type) }}</p>
<p class="text-xs text-blue-500 mt-1">{{ account.currency }}</p>
</div>
<div class="flex space-x-2">
<Button type="primary" size="small" block @click="transfer(account)">💸 转账</Button>
<Button size="small" block @click="viewDetails(account)">📊 明细</Button>
@@ -85,10 +84,10 @@
</Card>
</div>
<!-- 添加账户模态框 -->
<Modal
v-model:open="showAddModal"
title=" 添加新账户"
<!-- 添加/编辑账户模态框 -->
<Modal
v-model:open="showAddModal"
:title="isEditing ? '✏️ 编辑账户' : ' 添加新账户'"
@ok="submitAccount"
@cancel="cancelAdd"
width="500px"
@@ -220,21 +219,140 @@
</Form.Item>
</Form>
</Modal>
<!-- 转账模态框 -->
<Modal
v-model:open="showTransferModal"
title="💸 转账"
@ok="submitTransfer"
width="500px"
>
<Form layout="vertical">
<Form.Item label="转出账户">
<Select v-model:value="transferForm.fromAccount" disabled>
<Select.Option
v-for="account in accounts"
:key="account.id"
:value="account.id"
>
{{ account.icon }} {{ account.name }} ({{ getCurrencySymbol(account.currency) }}{{ account.balance.toLocaleString() }})
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="转入账户" required>
<Select v-model:value="transferForm.toAccount" placeholder="选择转入账户">
<Select.Option
v-for="account in accounts.filter(a => a.id !== transferForm.fromAccount)"
:key="account.id"
:value="account.id"
>
{{ account.icon }} {{ account.name }} ({{ getCurrencySymbol(account.currency) }}{{ account.balance.toLocaleString() }})
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="转账金额" required>
<InputNumber
v-model:value="transferForm.amount"
:min="0"
:precision="2"
style="width: 100%"
placeholder="请输入转账金额"
size="large"
/>
</Form.Item>
<Form.Item label="备注">
<Input.TextArea
v-model:value="transferForm.description"
:rows="3"
placeholder="转账备注(可选)"
/>
</Form.Item>
</Form>
</Modal>
<!-- 账户明细模态框 -->
<Modal
v-model:open="showDetailsModal"
:title="`📊 ${currentAccount?.name || ''} - 交易明细`"
width="900px"
:footer="null"
>
<div v-if="accountTransactions.length === 0" class="text-center py-12">
<div class="text-8xl mb-6">📊</div>
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无交易记录</h3>
<p class="text-gray-500">该账户还没有任何交易记录</p>
</div>
<Table
v-else
:columns="[
{ title: '日期', dataIndex: 'transactionDate', key: 'transactionDate', width: 120 },
{ title: '类型', dataIndex: 'type', key: 'type', width: 80 },
{ title: '描述', dataIndex: 'description', key: 'description' },
{ title: '分类', dataIndex: 'categoryId', key: 'categoryId', width: 120 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 150 }
]"
:dataSource="accountTransactions"
:pagination="{ pageSize: 10 }"
:rowKey="record => record.id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'type'">
<Tag :color="record.type === 'income' ? 'green' : 'red'">
{{ record.type === 'income' ? '📈 收入' : '📉 支出' }}
</Tag>
</template>
<template v-else-if="column.dataIndex === 'amount'">
<span :class="record.type === 'income' ? 'text-green-600 font-bold' : 'text-red-600 font-bold'">
{{ record.type === 'income' ? '+' : '-' }}{{ getCurrencySymbol(record.currency) }}{{ Math.abs(record.amount).toLocaleString() }}
</span>
</template>
<template v-else-if="column.dataIndex === 'categoryId'">
<Tag>{{ getCategoryName(record.categoryId) }}</Tag>
</template>
</template>
</Table>
</Modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
Card, Button, Modal, Form, Input, Select, Row, Col,
InputNumber, notification, Dropdown, Menu
import { ref, computed, onMounted } from 'vue';
import {
Card, Button, Modal, Form, Input, Select, Row, Col,
InputNumber, notification, Dropdown, Menu, Table, Tag, Space
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { useFinanceStore } from '#/store/finance';
defineOptions({ name: 'AccountManagement' });
const accounts = ref<any[]>([]);
const financeStore = useFinanceStore();
// 使用 financeStore 的账户数据
const accounts = computed(() => financeStore.accounts);
// 初始化时加载数据
onMounted(async () => {
await Promise.all([
financeStore.fetchAccounts(),
financeStore.fetchTransactions(),
]);
});
const showAddModal = ref(false);
const showTransferModal = ref(false);
const showDetailsModal = ref(false);
const formRef = ref();
const currentAccount = ref<any>(null);
const accountTransactions = computed(() => {
if (!currentAccount.value) return [];
return financeStore.transactions.filter(t =>
t.accountId === currentAccount.value.id && !t.isDeleted
);
});
// 计算属性
const totalAssets = computed(() => {
@@ -276,6 +394,15 @@ const accountForm = ref({
color: '#1890ff'
});
// 转账表单数据
const transferForm = ref({
fromAccount: '',
toAccount: '',
amount: null,
description: '',
date: null
});
// 账户颜色选项
const accountColors = ref([
'#1890ff', '#52c41a', '#fa541c', '#722ed1', '#eb2f96', '#13c2c2',
@@ -306,53 +433,79 @@ const submitAccount = async () => {
try {
// 表单验证
await formRef.value.validate();
// 处理自定义字段
const finalType = accountForm.value.type === 'CUSTOM'
? accountForm.value.customTypeName
const finalType = accountForm.value.type === 'CUSTOM'
? accountForm.value.customTypeName
: getAccountTypeText(accountForm.value.type);
const finalCurrency = accountForm.value.currency === 'CUSTOM'
? `${accountForm.value.customCurrencyCode} (${accountForm.value.customCurrencyName})`
: accountForm.value.currency;
const finalBank = accountForm.value.bank === 'CUSTOM'
? accountForm.value.customBankName
: accountForm.value.bank;
// 创建新账户
const newAccount = {
id: Date.now().toString(),
name: accountForm.value.name,
type: finalType,
balance: accountForm.value.balance || 0,
currency: finalCurrency,
bank: finalBank,
description: accountForm.value.description,
color: accountForm.value.color,
emoji: getAccountEmoji(accountForm.value.type),
createdAt: new Date().toISOString(),
status: 'active'
};
// 添加到账户列表
accounts.value.push(newAccount);
notification.success({
message: '账户添加成功',
description: `账户 "${newAccount.name}" 已成功创建`
});
if (isEditing.value && editingAccount.value) {
// 编辑现有账户
const index = accounts.value.findIndex(a => a.id === editingAccount.value.id);
if (index !== -1) {
accounts.value[index] = {
...accounts.value[index],
name: accountForm.value.name,
type: finalType,
balance: accountForm.value.balance || 0,
currency: finalCurrency,
bank: finalBank,
description: accountForm.value.description,
color: accountForm.value.color,
icon: getAccountEmoji(accountForm.value.type),
updatedAt: new Date().toISOString()
};
notification.success({
message: '账户更新成功',
description: `账户 "${accountForm.value.name}" 已更新`
});
}
} else {
// 创建新账户
const newAccount = {
id: Date.now().toString(),
name: accountForm.value.name,
type: finalType,
balance: accountForm.value.balance || 0,
currency: finalCurrency,
bank: finalBank,
description: accountForm.value.description,
color: accountForm.value.color,
icon: getAccountEmoji(accountForm.value.type),
createdAt: new Date().toISOString(),
status: 'active'
};
// 添加到账户列表
accounts.value.push(newAccount);
notification.success({
message: '账户添加成功',
description: `账户 "${newAccount.name}" 已成功创建`
});
console.log('新增账户:', newAccount);
}
// 关闭模态框
showAddModal.value = false;
isEditing.value = false;
editingAccount.value = null;
resetForm();
console.log('新增账户:', newAccount);
} catch (error) {
console.error('表单验证失败:', error);
notification.error({
message: '添加失败',
message: isEditing.value ? '更新失败' : '添加失败',
description: '请检查表单信息是否正确'
});
}
@@ -360,6 +513,8 @@ const submitAccount = async () => {
const cancelAdd = () => {
showAddModal.value = false;
isEditing.value = false;
editingAccount.value = null;
resetForm();
};
@@ -401,12 +556,32 @@ const resetForm = () => {
};
};
const getCurrencySymbol = (currency: string) => {
const symbolMap: Record<string, string> = {
'CNY': '¥',
'THB': '฿',
'USD': '$',
'EUR': '€',
'JPY': '¥',
'GBP': '£',
'HKD': 'HK$',
'KRW': '₩'
};
return symbolMap[currency] || currency + ' ';
};
const getAccountTypeText = (type: string) => {
const typeMap = {
const typeMap: Record<string, string> = {
'cash': '现金',
'bank': '银行卡',
'alipay': '支付宝',
'wechat': '微信',
'virtual_wallet': '虚拟钱包',
'investment': '投资账户',
'credit_card': '信用卡',
'savings': '储蓄账户',
'checking': '支票账户',
'credit': '信用卡',
'investment': '投资账户',
'ewallet': '电子钱包'
};
return typeMap[type] || type;
@@ -423,12 +598,34 @@ const getAccountEmoji = (type: string) => {
return emojiMap[type] || '🏦';
};
const isEditing = ref(false);
const editingAccount = ref<any>(null);
const editAccount = (account: any) => {
console.log('编辑账户:', account);
notification.info({
message: '编辑功能',
description: '账户编辑功能'
});
isEditing.value = true;
editingAccount.value = account;
// 填充表单
accountForm.value = {
name: account.name,
type: account.type === '储蓄账户' ? 'savings' :
account.type === '支票账户' ? 'checking' :
account.type === '信用卡' ? 'credit' :
account.type === '投资账户' ? 'investment' :
account.type === '电子钱包' ? 'ewallet' : 'CUSTOM',
customTypeName: ['储蓄账户', '支票账户', '信用卡', '投资账户', '电子钱包'].includes(account.type) ? '' : account.type,
balance: account.balance,
currency: ['CNY', 'USD', 'EUR', 'JPY', 'GBP', 'HKD', 'KRW'].includes(account.currency) ? account.currency : 'CUSTOM',
customCurrencyCode: ['CNY', 'USD', 'EUR', 'JPY', 'GBP', 'HKD', 'KRW'].includes(account.currency) ? '' : account.currency,
customCurrencyName: '',
bank: account.bank || '',
customBankName: '',
description: account.description || '',
color: account.color || '#1890ff'
};
showAddModal.value = true;
};
const deleteAccount = (account: any) => {
@@ -445,18 +642,98 @@ const deleteAccount = (account: any) => {
const transfer = (account: any) => {
console.log('转账功能:', account);
notification.info({
message: '转账功能',
description: `${account.name} 转账功能`
});
currentAccount.value = account;
transferForm.value = {
fromAccount: account.id,
toAccount: '',
amount: null,
description: '',
date: new Date()
};
showTransferModal.value = true;
};
const submitTransfer = async () => {
try {
if (!transferForm.value.toAccount || !transferForm.value.amount) {
notification.error({
message: '转账失败',
description: '请填写完整的转账信息'
});
return;
}
if (transferForm.value.fromAccount === transferForm.value.toAccount) {
notification.error({
message: '转账失败',
description: '转出和转入账户不能相同'
});
return;
}
const fromAccount = financeStore.getAccountById(Number(transferForm.value.fromAccount));
const toAccount = financeStore.getAccountById(Number(transferForm.value.toAccount));
if (!fromAccount || !toAccount) {
notification.error({
message: '转账失败',
description: '账户不存在'
});
return;
}
// 创建转出交易(支出)
await financeStore.createTransaction({
type: 'expense',
amount: transferForm.value.amount,
currency: fromAccount.currency,
accountId: Number(transferForm.value.fromAccount),
transactionDate: new Date().toISOString().split('T')[0],
description: `转账至 ${toAccount.name}${transferForm.value.description ? ' - ' + transferForm.value.description : ''}`
});
// 创建转入交易(收入)
await financeStore.createTransaction({
type: 'income',
amount: transferForm.value.amount,
currency: toAccount.currency,
accountId: Number(transferForm.value.toAccount),
transactionDate: new Date().toISOString().split('T')[0],
description: `${fromAccount.name} 转入${transferForm.value.description ? ' - ' + transferForm.value.description : ''}`
});
notification.success({
message: '转账成功',
description: `已从 ${fromAccount.name} 转账 ${getCurrencySymbol(fromAccount.currency)}${transferForm.value.amount}${toAccount.name}`
});
showTransferModal.value = false;
transferForm.value = {
fromAccount: '',
toAccount: '',
amount: null,
description: '',
date: null
};
} catch (error) {
console.error('转账失败:', error);
notification.error({
message: '转账失败',
description: '转账时出错,请稍后重试'
});
}
};
const viewDetails = (account: any) => {
console.log('查看明细:', account);
notification.info({
message: '账户明细',
description: `查看 ${account.name} 交易明细`
});
currentAccount.value = account;
showDetailsModal.value = true;
};
const getCategoryName = (categoryId: number | null) => {
if (!categoryId) return '未分类';
const category = financeStore.getCategoryById(categoryId);
return category ? `${category.icon} ${category.name}` : '未知分类';
};
</script>

View File

@@ -265,15 +265,18 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
import { ref, computed, onMounted } from 'vue';
import {
Card, Progress, Button, Modal, Form, Input, Select, Row, Col,
InputNumber, Slider, Switch, Tag, notification, Dropdown, Menu
} from 'ant-design-vue';
import { useFinanceStore } from '#/store/finance';
defineOptions({ name: 'BudgetManagement' });
const budgets = ref([]);
const financeStore = useFinanceStore();
const budgets = computed(() => financeStore.budgets.filter(b => !b.isDeleted));
const showAddModal = ref(false);
const formRef = ref();
@@ -406,20 +409,20 @@ const submitBudget = async () => {
try {
// 表单验证
await formRef.value.validate();
// 处理自定义字段
const finalCategory = budgetForm.value.category === 'CUSTOM'
? budgetForm.value.customCategoryName
const finalCategory = budgetForm.value.category === 'CUSTOM'
? budgetForm.value.customCategoryName
: getCategoryName(budgetForm.value.category);
const finalEmoji = budgetForm.value.category === 'CUSTOM'
? budgetForm.value.customCategoryIcon
: getCategoryEmoji(budgetForm.value.category);
const finalCurrency = budgetForm.value.currency === 'CUSTOM'
? `${budgetForm.value.customCurrencyCode} (${budgetForm.value.customCurrencyName})`
: budgetForm.value.currency;
// 检查分类是否已有预算
const existingBudget = budgets.value.find(b => b.category === finalCategory);
if (existingBudget) {
@@ -429,15 +432,14 @@ const submitBudget = async () => {
});
return;
}
// 创建新预算
const newBudget = {
id: Date.now().toString(),
await financeStore.createBudget({
category: finalCategory,
emoji: finalEmoji,
limit: budgetForm.value.limit,
currency: finalCurrency,
spent: 0, // 初始已用金额为0
spent: 0,
remaining: budgetForm.value.limit,
percentage: 0,
period: budgetForm.value.period,
@@ -447,23 +449,17 @@ const submitBudget = async () => {
overspendAlert: budgetForm.value.overspendAlert,
dailyReminder: budgetForm.value.dailyReminder,
monthlyTrend: 0,
createdAt: new Date().toISOString()
};
// 添加到预算列表
budgets.value.push(newBudget);
});
notification.success({
message: '预算设置成功',
description: `${newBudget.category} 预算已成功创建`
description: `${finalCategory} 预算已成功创建`
});
// 关闭模态框
showAddModal.value = false;
resetForm();
console.log('新增预算:', newBudget);
} catch (error) {
console.error('表单验证失败:', error);
notification.error({
@@ -537,17 +533,18 @@ const viewHistory = (budget: any) => {
});
};
const deleteBudget = (budget: any) => {
const deleteBudget = async (budget: any) => {
console.log('删除预算:', budget);
const index = budgets.value.findIndex(b => b.id === budget.id);
if (index !== -1) {
budgets.value.splice(index, 1);
notification.success({
message: '预算已删除',
description: `${budget.category} 预算已删除`
});
}
await financeStore.deleteBudget(budget.id);
notification.success({
message: '预算已删除',
description: `${budget.category} 预算已删除`
});
};
onMounted(async () => {
await financeStore.fetchBudgets();
});
</script>
<style scoped>

View File

@@ -16,34 +16,24 @@
<div v-for="category in categories" :key="category.id" class="p-4 border rounded-lg hover:shadow-md transition-shadow">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<span class="text-xl" :style="{ color: category.color }">{{ category.emoji }}</span>
<span class="text-2xl">{{ category.icon }}</span>
<div>
<span class="font-medium text-lg">{{ category.name }}</span>
<div class="flex items-center space-x-2 mt-1">
<Tag :color="category.type === 'income' ? 'green' : 'red'" size="small">
{{ category.type === 'income' ? '📈 收入' : '📉 支出' }}
</Tag>
<Tag size="small">{{ category.count }}笔交易</Tag>
<Tag v-if="category.budget > 0" color="blue" size="small">
预算{{ category.budget.toLocaleString() }} {{ category.budgetCurrency || 'CNY' }}
</Tag>
<Tag v-if="category.isSystem" color="blue" size="small">系统分类</Tag>
</div>
</div>
</div>
<div class="text-right">
<p class="text-lg font-semibold" :class="category.type === 'income' ? 'text-green-600' : 'text-red-600'">
{{ category.amount.toLocaleString('zh-CN', { style: 'currency', currency: 'CNY' }) }}
</p>
<div class="mt-2 space-x-2">
<div class="space-x-2">
<Button type="link" size="small" @click="editCategory(category)"> 编辑</Button>
<Button type="link" size="small" @click="setBudget(category)">🎯 预算</Button>
<Button type="link" size="small" danger @click="deleteCategory(category)">🗑 删除</Button>
<Button type="link" size="small" danger @click="deleteCategory(category)" :disabled="category.isSystem">🗑 删除</Button>
</div>
</div>
</div>
<div v-if="category.description" class="mt-2 text-sm text-gray-500">
{{ category.description }}
</div>
</div>
</div>
</Card>
@@ -80,12 +70,13 @@
<div class="space-y-2">
<h4 class="font-medium">📈 收入分类</h4>
<div class="space-y-2">
<div v-for="category in incomeCategories" :key="category.id"
<div v-for="category in incomeCategories" :key="category.id"
class="flex items-center justify-between p-2 bg-green-50 rounded">
<span>{{ category.emoji }} {{ category.name }}</span>
<span class="text-green-600 font-medium">
{{ category.amount.toLocaleString('zh-CN', { style: 'currency', currency: 'CNY' }) }}
</span>
<div class="flex items-center space-x-2">
<span>{{ category.icon }}</span>
<span>{{ category.name }}</span>
</div>
<Tag :color="category.color" size="small">{{ category.isSystem ? '系统' : '自定义' }}</Tag>
</div>
<div v-if="incomeCategories.length === 0" class="text-center text-gray-500 py-2">
暂无收入分类
@@ -94,12 +85,13 @@
<h4 class="font-medium mt-4">📉 支出分类</h4>
<div class="space-y-2">
<div v-for="category in expenseCategories" :key="category.id"
<div v-for="category in expenseCategories" :key="category.id"
class="flex items-center justify-between p-2 bg-red-50 rounded">
<span>{{ category.emoji }} {{ category.name }}</span>
<span class="text-red-600 font-medium">
{{ category.amount.toLocaleString('zh-CN', { style: 'currency', currency: 'CNY' }) }}
</span>
<div class="flex items-center space-x-2">
<span>{{ category.icon }}</span>
<span>{{ category.name }}</span>
</div>
<Tag :color="category.color" size="small">{{ category.isSystem ? '系统' : '自定义' }}</Tag>
</div>
<div v-if="expenseCategories.length === 0" class="text-center text-gray-500 py-2">
暂无支出分类
@@ -110,10 +102,73 @@
</Card>
</div>
<!-- 编辑分类模态框 -->
<Modal
v-model:open="showEditModal"
title="✏️ 编辑分类"
@ok="submitEditCategory"
@cancel="() => { showEditModal = false; editingCategory = null; }"
width="500px"
>
<Form
ref="editFormRef"
:model="editForm"
:rules="rules"
layout="vertical"
class="mt-4"
>
<Form.Item label="分类名称" name="name" required>
<Input
v-model:value="editForm.name"
placeholder="请输入分类名称"
size="large"
/>
</Form.Item>
<Form.Item label="图标" name="icon">
<Select v-model:value="editForm.icon" placeholder="选择图标" size="large">
<Select.Option value="🍽️">🍽 餐饮</Select.Option>
<Select.Option value="🚗">🚗 交通</Select.Option>
<Select.Option value="🛒">🛒 购物</Select.Option>
<Select.Option value="🎮">🎮 娱乐</Select.Option>
<Select.Option value="💻">💻 软件订阅</Select.Option>
<Select.Option value="📊">📊 投资</Select.Option>
<Select.Option value="🏥">🏥 医疗</Select.Option>
<Select.Option value="🏠">🏠 住房</Select.Option>
<Select.Option value="📚">📚 教育</Select.Option>
<Select.Option value="💰">💰 工资</Select.Option>
<Select.Option value="🎁">🎁 奖金</Select.Option>
<Select.Option value="💼">💼 副业</Select.Option>
<Select.Option value="CUSTOM"> 自定义图标</Select.Option>
</Select>
</Form.Item>
<!-- 自定义图标输入 -->
<div v-if="editForm.icon === 'CUSTOM'" class="mb-4">
<Form.Item label="自定义图标" required>
<Input v-model:value="editForm.customIcon" placeholder="请输入一个表情符号,如: 🍕, 🎬, 📚 等" />
</Form.Item>
</div>
<Form.Item label="分类颜色">
<div class="flex space-x-2">
<div
v-for="color in categoryColors"
:key="color"
class="w-8 h-8 rounded-full cursor-pointer border-2 hover:scale-110 transition-all"
:class="editForm.color === color ? 'border-gray-800 scale-110' : 'border-gray-300'"
:style="{ backgroundColor: color }"
@click="editForm.color = color"
></div>
</div>
</Form.Item>
</Form>
</Modal>
<!-- 添加分类模态框 -->
<Modal
v-model:open="showAddModal"
title=" 添加新分类"
<Modal
v-model:open="showAddModal"
title=" 添加新分类"
@ok="submitCategory"
@cancel="cancelAdd"
width="500px"
@@ -236,17 +291,32 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
Card, Tag, Button, Modal, Form, Input, Select, Row, Col,
InputNumber, notification
import { ref, computed, onMounted } from 'vue';
import {
Card, Tag, Button, Modal, Form, Input, Select, Row, Col,
InputNumber, notification
} from 'ant-design-vue';
import { useFinanceStore } from '#/store/finance';
defineOptions({ name: 'CategoryManagement' });
const categories = ref([]);
const financeStore = useFinanceStore();
// 使用 financeStore 的分类数据
const categories = computed(() => {
return [...financeStore.incomeCategories, ...financeStore.expenseCategories];
});
// 初始化时加载数据
onMounted(async () => {
await financeStore.fetchCategories();
});
const showAddModal = ref(false);
const showEditModal = ref(false);
const editingCategory = ref<any>(null);
const formRef = ref();
const editFormRef = ref();
// 表单数据
const categoryForm = ref({
@@ -262,6 +332,14 @@ const categoryForm = ref({
color: '#1890ff'
});
// 编辑表单数据
const editForm = ref({
name: '',
icon: '🏷️',
customIcon: '',
color: '#1890ff'
});
// 分类颜色选项
const categoryColors = ref([
'#1890ff', '#52c41a', '#fa541c', '#722ed1', '#eb2f96', '#13c2c2',
@@ -283,12 +361,12 @@ const rules = {
const categoryStats = computed(() => {
const incomeCategories = categories.value.filter(c => c.type === 'income');
const expenseCategories = categories.value.filter(c => c.type === 'expense');
return {
total: categories.value.length,
income: incomeCategories.length,
expense: expenseCategories.length,
budgetTotal: categories.value.reduce((sum, c) => sum + (c.budget || 0), 0)
budgetTotal: 0 // 预算功能待实现
};
});
@@ -311,48 +389,31 @@ const submitCategory = async () => {
try {
// 表单验证
await formRef.value.validate();
// 处理自定义字段
const finalIcon = categoryForm.value.icon === 'CUSTOM'
? categoryForm.value.customIcon
// 处理自定义图标
const finalIcon = categoryForm.value.icon === 'CUSTOM'
? categoryForm.value.customIcon
: categoryForm.value.icon;
const finalBudgetCurrency = categoryForm.value.budgetCurrency === 'CUSTOM'
? `${categoryForm.value.customCurrencyCode} (${categoryForm.value.customCurrencyName})`
: categoryForm.value.budgetCurrency;
// 创建新分类
const newCategory = {
id: Date.now().toString(),
// 调用 store 创建分类
await financeStore.createCategory({
name: categoryForm.value.name,
type: categoryForm.value.type,
icon: finalIcon,
budget: categoryForm.value.budget || 0,
budgetCurrency: finalBudgetCurrency,
description: categoryForm.value.description,
color: categoryForm.value.color,
count: 0, // 交易数量
amount: 0, // 总金额
createdAt: new Date().toISOString(),
emoji: finalIcon
};
// 添加到分类列表
categories.value.push(newCategory);
});
notification.success({
message: '分类添加成功',
description: `分类 "${newCategory.name}" 已成功创建`
description: `分类 "${categoryForm.value.name}" 已成功创建`
});
// 关闭模态框
showAddModal.value = false;
resetForm();
console.log('新增分类:', newCategory);
} catch (error) {
console.error('表单验证失败:', error);
console.error('创建分类失败:', error);
notification.error({
message: '添加失败',
description: '请检查表单信息是否正确'
@@ -396,23 +457,81 @@ const handleBudgetCurrencyChange = (currency: string) => {
};
const editCategory = (category: any) => {
console.log('编辑分类:', category);
notification.info({
message: '编辑功能',
description: `编辑分类 "${category.name}"`
});
editingCategory.value = category;
editForm.value = {
name: category.name,
icon: category.icon,
customIcon: '',
color: category.color || '#1890ff',
};
showEditModal.value = true;
};
const submitEditCategory = async () => {
try {
await editFormRef.value?.validate();
// 处理自定义图标
const finalIcon = editForm.value.icon === 'CUSTOM'
? editForm.value.customIcon
: editForm.value.icon;
// 调用 store 更新分类
await financeStore.updateCategory(editingCategory.value.id, {
name: editForm.value.name,
icon: finalIcon,
color: editForm.value.color,
});
notification.success({
message: '分类更新成功',
description: `分类 "${editForm.value.name}" 已更新`
});
showEditModal.value = false;
editingCategory.value = null;
} catch (error) {
console.error('更新分类失败:', error);
notification.error({
message: '更新失败',
description: '请检查表单信息是否正确'
});
}
};
const deleteCategory = (category: any) => {
console.log('删除分类:', category);
const index = categories.value.findIndex(c => c.id === category.id);
if (index !== -1) {
categories.value.splice(index, 1);
notification.success({
message: '分类已删除',
description: `分类 "${category.name}" 已删除`
// 系统分类不允许删除
if (category.isSystem) {
notification.warning({
message: '无法删除',
description: '系统分类不允许删除'
});
return;
}
Modal.confirm({
title: '确认删除',
content: `确定要删除分类 "${category.name}" 吗?此操作不可恢复。`,
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await financeStore.deleteCategory(category.id);
notification.success({
message: '分类已删除',
description: `分类 "${category.name}" 已删除`
});
} catch (error) {
console.error('删除分类失败:', error);
notification.error({
message: '删除失败',
description: '删除分类时出错,请稍后重试'
});
}
}
});
};
const setBudget = (category: any) => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,788 @@
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">📈 报表分析</h1>
<p class="text-gray-600">全面的财务数据分析与报表</p>
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">📈 财务报表</h1>
<p class="text-gray-600">全面的财务数据分析与报表生成</p>
</div>
<div class="flex space-x-2">
<Button @click="showImportModal = true">
📤 导入报表
</Button>
<Button @click="showExportModal = true" type="primary">
📥 导出报表
</Button>
<Button @click="printReport">
🖨 打印报表
</Button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card title="📊 现金流分析">
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
<div class="text-center">
<div class="text-4xl mb-2">📈</div>
<p class="text-gray-600">现金流趋势图</p>
<!-- 时间筛选 -->
<Card class="mb-6">
<div class="flex items-center space-x-4">
<span class="font-medium">报表周期</span>
<Radio.Group v-model:value="period" button-style="solid">
<Radio.Button value="month">本月</Radio.Button>
<Radio.Button value="quarter">本季度</Radio.Button>
<Radio.Button value="year">本年</Radio.Button>
<Radio.Button value="all">全部</Radio.Button>
</Radio.Group>
<RangePicker v-if="period === 'custom'" v-model:value="customRange" />
</div>
</Card>
<!-- 核心指标汇总 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card class="text-center hover:shadow-lg transition-shadow">
<div class="text-3xl mb-2">💰</div>
<p class="text-sm text-gray-500">总收入</p>
<p class="text-2xl font-bold text-green-600">
¥{{ periodIncome.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
</p>
</Card>
<Card class="text-center hover:shadow-lg transition-shadow">
<div class="text-3xl mb-2">💸</div>
<p class="text-sm text-gray-500">总支出</p>
<p class="text-2xl font-bold text-red-600">
¥{{ periodExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
</p>
</Card>
<Card class="text-center hover:shadow-lg transition-shadow">
<div class="text-3xl mb-2">💎</div>
<p class="text-sm text-gray-500">净收入</p>
<p class="text-2xl font-bold" :class="periodNet >= 0 ? 'text-purple-600' : 'text-red-600'">
{{ periodNet >= 0 ? '+' : '' }}¥{{ periodNet.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
</p>
</Card>
<Card class="text-center hover:shadow-lg transition-shadow">
<div class="text-3xl mb-2">📊</div>
<p class="text-sm text-gray-500">交易笔数</p>
<p class="text-2xl font-bold text-blue-600">{{ periodTransactions.length }}</p>
</Card>
</div>
<!-- 详细报表 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- 收入分析 -->
<Card title="📈 收入分析">
<div class="space-y-3">
<div v-if="incomeByCategory.length === 0" class="text-center py-8">
<p class="text-gray-500">暂无收入数据</p>
</div>
<div v-else v-for="item in incomeByCategory" :key="item.categoryId" class="p-3 bg-gray-50 rounded-lg">
<div class="flex justify-between items-center mb-2">
<span class="font-medium">{{ item.categoryName }}</span>
<span class="text-sm font-bold text-green-600">
¥{{ item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
</span>
</div>
<div class="flex items-center space-x-2">
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div
class="bg-gradient-to-r from-green-400 to-green-600 h-2 rounded-full transition-all duration-500"
:style="{ width: item.percentage + '%' }"
></div>
</div>
<span class="text-xs text-gray-500 w-12 text-right">{{ item.percentage }}%</span>
</div>
<p class="text-xs text-gray-500 mt-1">{{ item.count }} · 平均 ¥{{ (item.amount / item.count).toFixed(2) }}</p>
</div>
</div>
</Card>
<Card title="🥧 支出分析">
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
<div class="text-center">
<div class="text-4xl mb-2">🍰</div>
<p class="text-gray-600">支出分布图</p>
<!-- 支出分析 -->
<Card title="📉 支出分析">
<div class="space-y-3">
<div v-if="expenseByCategory.length === 0" class="text-center py-8">
<p class="text-gray-500">暂无支出数据</p>
</div>
<div v-else v-for="item in expenseByCategory" :key="item.categoryId" class="p-3 bg-gray-50 rounded-lg">
<div class="flex justify-between items-center mb-2">
<span class="font-medium">{{ item.categoryName }}</span>
<span class="text-sm font-bold text-red-600">
¥{{ item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
</span>
</div>
<div class="flex items-center space-x-2">
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div
class="bg-gradient-to-r from-red-400 to-red-600 h-2 rounded-full transition-all duration-500"
:style="{ width: item.percentage + '%' }"
></div>
</div>
<span class="text-xs text-gray-500 w-12 text-right">{{ item.percentage }}%</span>
</div>
<p class="text-xs text-gray-500 mt-1">{{ item.count }} · 平均 ¥{{ (item.amount / item.count).toFixed(2) }}</p>
</div>
</div>
</Card>
</div>
<!-- 交易明细表 -->
<Card title="📋 交易明细">
<Table
:columns="columns"
:dataSource="periodTransactions"
:pagination="{ pageSize: 20 }"
:rowKey="record => record.id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'type'">
<Tag :color="record.type === 'income' ? 'green' : 'red'">
{{ record.type === 'income' ? '📈 收入' : '📉 支出' }}
</Tag>
</template>
<template v-else-if="column.dataIndex === 'amount'">
<span :class="record.type === 'income' ? 'text-green-600 font-bold' : 'text-red-600 font-bold'">
{{ record.type === 'income' ? '+' : '-' }}¥{{ Math.abs(record.amount).toLocaleString() }}
</span>
</template>
<template v-else-if="column.dataIndex === 'categoryId'">
<Tag>{{ getCategoryName(record.categoryId) }}</Tag>
</template>
<template v-else-if="column.dataIndex === 'accountId'">
{{ getAccountName(record.accountId) }}
</template>
</template>
</Table>
</Card>
<!-- 导出报表模态框 -->
<Modal v-model:open="showExportModal" title="📥 导出财务报表" @ok="handleExportReport" width="600px">
<Form layout="vertical">
<Form.Item label="导出格式" required>
<Radio.Group v-model:value="exportOptions.format" size="large">
<Radio.Button value="pdf">📄 PDF 格式</Radio.Button>
<Radio.Button value="excel">📊 Excel 格式</Radio.Button>
<Radio.Button value="csv">📋 CSV 格式</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label="包含内容">
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" v-model="exportOptions.includeSummary" class="mr-2" /> 核心指标汇总
</label>
<label class="flex items-center">
<input type="checkbox" v-model="exportOptions.includeIncome" class="mr-2" /> 收入分析
</label>
<label class="flex items-center">
<input type="checkbox" v-model="exportOptions.includeExpense" class="mr-2" /> 支出分析
</label>
<label class="flex items-center">
<input type="checkbox" v-model="exportOptions.includeTransactions" class="mr-2" /> 交易明细
</label>
</div>
</Form.Item>
<Form.Item label="报表标题">
<Input v-model:value="exportOptions.title" placeholder="财务报表" />
</Form.Item>
</Form>
</Modal>
<!-- 导入报表模态框 -->
<Modal v-model:open="showImportModal" title="📤 导入财务报表数据" @ok="handleImportReport" width="700px">
<Form layout="vertical">
<Form.Item label="上传文件" required>
<input
type="file"
accept=".xlsx,.xls,.csv,.json"
@change="handleReportFileUpload"
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded-full file:border-0
file:text-sm file:font-semibold
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
/>
<p class="text-sm text-gray-500 mt-2">支持 Excel (.xlsx, .xls)CSV JSON 格式</p>
</Form.Item>
<div v-if="importPreviewData.length > 0">
<Form.Item label="数据预览">
<div class="border rounded-lg overflow-auto max-h-60">
<table class="w-full text-sm">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-4 py-2 text-left">日期</th>
<th class="px-4 py-2 text-left">类型</th>
<th class="px-4 py-2 text-left">分类</th>
<th class="px-4 py-2 text-left">金额</th>
<th class="px-4 py-2 text-left">描述</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in importPreviewData.slice(0, 5)" :key="idx" class="border-t">
<td class="px-4 py-2">{{ row.date || row['日期'] }}</td>
<td class="px-4 py-2">{{ row.type || row['类型'] }}</td>
<td class="px-4 py-2">{{ row.category || row['分类'] }}</td>
<td class="px-4 py-2">{{ row.amount || row['金额'] }}</td>
<td class="px-4 py-2">{{ row.description || row['描述'] || row['项目名称'] }}</td>
</tr>
</tbody>
</table>
</div>
<p class="text-sm text-gray-500 mt-2">
预览前 5 条数据 {{ importPreviewData.length }} 条待导入
</p>
</Form.Item>
</div>
</Form>
</Modal>
</div>
</template>
<script setup lang="ts">
import { Card } from 'ant-design-vue';
import { ref, computed, onMounted } from 'vue';
import { Card, Button, Radio, DatePicker, Table, Tag, notification, Modal, Form, Input } from 'ant-design-vue';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
import { useFinanceStore } from '#/store/finance';
defineOptions({ name: 'ReportsAnalytics' });
const financeStore = useFinanceStore();
const { RangePicker } = DatePicker;
const period = ref('month');
const customRange = ref();
// 导出和导入状态
const showExportModal = ref(false);
const showImportModal = ref(false);
const importPreviewData = ref<any[]>([]);
const exportOptions = ref({
format: 'excel' as 'pdf' | 'excel' | 'csv',
includeSummary: true,
includeIncome: true,
includeExpense: true,
includeTransactions: true,
title: '财务报表'
});
// 获取周期内的交易
const periodTransactions = computed(() => {
const now = dayjs();
let startDate: dayjs.Dayjs;
switch (period.value) {
case 'month':
startDate = now.startOf('month');
break;
case 'quarter':
startDate = now.startOf('quarter');
break;
case 'year':
startDate = now.startOf('year');
break;
case 'all':
return financeStore.transactions.filter(t => !t.isDeleted);
default:
return financeStore.transactions.filter(t => !t.isDeleted);
}
return financeStore.transactions.filter(t =>
!t.isDeleted && dayjs(t.transactionDate).isAfter(startDate)
);
});
// 周期收入
const periodIncome = computed(() => {
return periodTransactions.value
.filter(t => t.type === 'income')
.reduce((sum, t) => sum + t.amountInBase, 0);
});
// 周期支出
const periodExpense = computed(() => {
return periodTransactions.value
.filter(t => t.type === 'expense')
.reduce((sum, t) => sum + t.amountInBase, 0);
});
// 周期净收入
const periodNet = computed(() => periodIncome.value - periodExpense.value);
// 按分类统计收入
const incomeByCategory = computed(() => {
const incomeTransactions = periodTransactions.value.filter(t => t.type === 'income');
if (incomeTransactions.length === 0) return [];
const categoryMap = new Map();
incomeTransactions.forEach(t => {
const categoryId = t.categoryId || 0;
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
categoryId,
categoryName: financeStore.getCategoryById(categoryId)?.name || '未分类',
amount: 0,
count: 0
});
}
const category = categoryMap.get(categoryId);
category.amount += t.amountInBase;
category.count += 1;
});
return Array.from(categoryMap.values())
.map(item => ({
...item,
percentage: Math.round((item.amount / periodIncome.value) * 100)
}))
.sort((a, b) => b.amount - a.amount);
});
// 按分类统计支出
const expenseByCategory = computed(() => {
const expenseTransactions = periodTransactions.value.filter(t => t.type === 'expense');
if (expenseTransactions.length === 0) return [];
const categoryMap = new Map();
expenseTransactions.forEach(t => {
const categoryId = t.categoryId || 0;
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
categoryId,
categoryName: financeStore.getCategoryById(categoryId)?.name || '未分类',
amount: 0,
count: 0
});
}
const category = categoryMap.get(categoryId);
category.amount += t.amountInBase;
category.count += 1;
});
return Array.from(categoryMap.values())
.map(item => ({
...item,
percentage: Math.round((item.amount / periodExpense.value) * 100)
}))
.sort((a, b) => b.amount - a.amount);
});
// 表格列
const columns = [
{ title: '日期', dataIndex: 'transactionDate', key: 'transactionDate', width: 120 },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
{ title: '描述', dataIndex: 'description', key: 'description' },
{ title: '分类', dataIndex: 'categoryId', key: 'categoryId', width: 120 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 150 },
{ title: '账户', dataIndex: 'accountId', key: 'accountId', width: 120 }
];
const getCategoryName = (categoryId: number | null) => {
if (!categoryId) return '未分类';
const category = financeStore.getCategoryById(categoryId);
return category ? `${category.icon} ${category.name}` : '未知分类';
};
const getAccountName = (accountId: number) => {
const account = financeStore.getAccountById(accountId);
return account ? `${account.icon} ${account.name}` : '未知账户';
};
// 导出报表
const handleExportReport = () => {
try {
const timestamp = new Date().toISOString().split('T')[0];
const title = exportOptions.value.title || '财务报表';
if (exportOptions.value.format === 'excel') {
exportToExcel(title, timestamp);
} else if (exportOptions.value.format === 'csv') {
exportToCSV(title, timestamp);
} else if (exportOptions.value.format === 'pdf') {
notification.info({
message: 'PDF 格式',
description: 'PDF 导出功能开发中,暂时使用 Excel 代替'
});
exportToExcel(title, timestamp);
}
notification.success({
message: '导出成功',
description: `报表已成功导出`
});
showExportModal.value = false;
} catch (error) {
console.error('导出失败:', error);
notification.error({
message: '导出失败',
description: '报表导出过程中出现错误'
});
}
};
// 导出为 Excel
const exportToExcel = (title: string, timestamp: string) => {
const workbook = XLSX.utils.book_new();
// 核心指标汇总
if (exportOptions.value.includeSummary) {
const summaryData = [
['核心指标汇总', '', '', ''],
['指标', '金额', '', ''],
['总收入', `¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`, '', ''],
['总支出', `¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`, '', ''],
['净收入', `¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`, '', ''],
['交易笔数', periodTransactions.value.length, '', '']
];
const summarySheet = XLSX.utils.aoa_to_sheet(summaryData);
XLSX.utils.book_append_sheet(workbook, summarySheet, '核心指标');
}
// 收入分析
if (exportOptions.value.includeIncome && incomeByCategory.value.length > 0) {
const incomeData = incomeByCategory.value.map(item => ({
'分类': item.categoryName,
'金额': item.amount,
'笔数': item.count,
'平均': (item.amount / item.count).toFixed(2),
'占比': `${item.percentage}%`
}));
const incomeSheet = XLSX.utils.json_to_sheet(incomeData);
XLSX.utils.book_append_sheet(workbook, incomeSheet, '收入分析');
}
// 支出分析
if (exportOptions.value.includeExpense && expenseByCategory.value.length > 0) {
const expenseData = expenseByCategory.value.map(item => ({
'分类': item.categoryName,
'金额': item.amount,
'笔数': item.count,
'平均': (item.amount / item.count).toFixed(2),
'占比': `${item.percentage}%`
}));
const expenseSheet = XLSX.utils.json_to_sheet(expenseData);
XLSX.utils.book_append_sheet(workbook, expenseSheet, '支出分析');
}
// 交易明细
if (exportOptions.value.includeTransactions && periodTransactions.value.length > 0) {
const transactionsData = periodTransactions.value.map(t => ({
'日期': t.transactionDate,
'类型': t.type === 'income' ? '收入' : '支出',
'描述': t.description || '',
'分类': getCategoryName(t.categoryId),
'金额': t.amount,
'币种': t.currency,
'账户': getAccountName(t.accountId)
}));
const transactionsSheet = XLSX.utils.json_to_sheet(transactionsData);
XLSX.utils.book_append_sheet(workbook, transactionsSheet, '交易明细');
}
// 生成文件
XLSX.writeFile(workbook, `${title}-${timestamp}.xlsx`);
};
// 导出为 CSV
const exportToCSV = (title: string, timestamp: string) => {
let csvContent = '';
// 核心指标汇总
if (exportOptions.value.includeSummary) {
csvContent += '核心指标汇总\n';
csvContent += '指标,金额\n';
csvContent += `总收入,¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
csvContent += `总支出,¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
csvContent += `净收入,¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
csvContent += `交易笔数,${periodTransactions.value.length}\n\n`;
}
// 交易明细
if (exportOptions.value.includeTransactions && periodTransactions.value.length > 0) {
csvContent += '交易明细\n';
csvContent += '日期,类型,描述,分类,金额,币种,账户\n';
periodTransactions.value.forEach(t => {
csvContent += `${t.transactionDate},${t.type === 'income' ? '收入' : '支出'},"${t.description || ''}",${getCategoryName(t.categoryId)},${t.amount},${t.currency},${getAccountName(t.accountId)}\n`;
});
}
// 下载文件
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title}-${timestamp}.csv`;
a.click();
URL.revokeObjectURL(url);
};
// 打印报表
const printReport = () => {
try {
const printWindow = window.open('', '_blank');
if (!printWindow) {
notification.error({
message: '打印失败',
description: '无法打开打印窗口,请检查浏览器设置'
});
return;
}
const periodText = {
month: '本月',
quarter: '本季度',
year: '本年',
all: '全部'
}[period.value] || '自定义';
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>财务报表 - ${periodText}</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
h1 { text-align: center; color: #333; }
h2 { color: #666; margin-top: 30px; border-bottom: 2px solid #eee; padding-bottom: 10px; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin: 20px 0; }
.summary-card { border: 1px solid #eee; padding: 15px; border-radius: 8px; text-align: center; }
.summary-card .label { color: #888; font-size: 14px; }
.summary-card .value { font-size: 24px; font-weight: bold; margin-top: 5px; }
.income { color: #52c41a; }
.expense { color: #f5222d; }
.net { color: #722ed1; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background-color: #f5f5f5; font-weight: bold; }
tr:nth-child(even) { background-color: #fafafa; }
.category-item { margin: 10px 0; padding: 10px; background: #f9f9f9; border-radius: 5px; }
.category-name { font-weight: bold; }
.category-amount { float: right; }
@media print {
.no-print { display: none; }
}
</style>
</head>
<body>
<h1>📈 财务报表</h1>
<p style="text-align: center; color: #888;">报表周期: ${periodText} · 生成时间: ${new Date().toLocaleString('zh-CN')}</p>
<h2>核心指标汇总</h2>
<div class="summary">
<div class="summary-card">
<div class="label">总收入</div>
<div class="value income">¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
</div>
<div class="summary-card">
<div class="label">总支出</div>
<div class="value expense">¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
</div>
<div class="summary-card">
<div class="label">净收入</div>
<div class="value net">${periodNet.value >= 0 ? '+' : ''}¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
</div>
<div class="summary-card">
<div class="label">交易笔数</div>
<div class="value">${periodTransactions.value.length}</div>
</div>
</div>
${incomeByCategory.value.length > 0 ? `
<h2>收入分析</h2>
<div>
${incomeByCategory.value.map(item => `
<div class="category-item">
<span class="category-name">${item.categoryName}</span>
<span class="category-amount income">¥${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
<div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;">
${item.count} 笔 · 平均 ¥${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
</div>
</div>
`).join('')}
</div>
` : ''}
${expenseByCategory.value.length > 0 ? `
<h2>支出分析</h2>
<div>
${expenseByCategory.value.map(item => `
<div class="category-item">
<span class="category-name">${item.categoryName}</span>
<span class="category-amount expense">¥${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
<div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;">
${item.count} 笔 · 平均 ¥${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
</div>
</div>
`).join('')}
</div>
` : ''}
${periodTransactions.value.length > 0 ? `
<h2>交易明细</h2>
<table>
<thead>
<tr>
<th>日期</th>
<th>类型</th>
<th>描述</th>
<th>分类</th>
<th>金额</th>
<th>账户</th>
</tr>
</thead>
<tbody>
${periodTransactions.value.map(t => `
<tr>
<td>${t.transactionDate}</td>
<td>${t.type === 'income' ? '📈 收入' : '📉 支出'}</td>
<td>${t.description || ''}</td>
<td>${getCategoryName(t.categoryId)}</td>
<td class="${t.type === 'income' ? 'income' : 'expense'}">
${t.type === 'income' ? '+' : '-'}¥${Math.abs(t.amount).toLocaleString()}
</td>
<td>${getAccountName(t.accountId)}</td>
</tr>
`).join('')}
</tbody>
</table>
` : ''}
<div class="no-print" style="text-align: center; margin-top: 40px;">
<button onclick="window.print()" style="padding: 10px 30px; font-size: 16px; cursor: pointer;">🖨️ 打印</button>
<button onclick="window.close()" style="padding: 10px 30px; font-size: 16px; margin-left: 10px; cursor: pointer;">关闭</button>
</div>
</body>
</html>
`);
printWindow.document.close();
} catch (error) {
console.error('打印失败:', error);
notification.error({
message: '打印失败',
description: '生成打印预览时出现错误'
});
}
};
// 处理报表文件上传
const handleReportFileUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (fileExtension === 'json') {
const text = await file.text();
const data = JSON.parse(text);
importPreviewData.value = Array.isArray(data) ? data : [data];
} else if (fileExtension === 'csv') {
const text = await file.text();
const lines = text.split('\n').filter(line => line.trim());
if (lines.length < 2) throw new Error('CSV 文件格式不正确');
const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
const data = lines.slice(1).map(line => {
const values = line.split(',').map(v => v.trim().replace(/^"|"$/g, ''));
const row: any = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
return row;
});
importPreviewData.value = data;
} else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const data = XLSX.utils.sheet_to_json(worksheet);
importPreviewData.value = data;
}
} catch (error) {
console.error('文件解析失败:', error);
notification.error({
message: '文件解析失败',
description: '请检查文件格式是否正确'
});
}
};
// 处理导入报表
const handleImportReport = async () => {
try {
if (importPreviewData.value.length === 0) {
notification.warning({
message: '无数据',
description: '请先上传文件'
});
return;
}
let successCount = 0;
let failCount = 0;
for (const row of importPreviewData.value) {
try {
const type = row.type || row['类型'];
const typeValue = type === '收入' || type === 'income' ? 'income' : 'expense';
// 查找分类
const categoryName = (row.category || row['分类'] || '').replace(/[^\u4e00-\u9fa5a-zA-Z]/g, '');
const categories = typeValue === 'income'
? financeStore.incomeCategories
: financeStore.expenseCategories;
const category = categories.find(c => c.name === categoryName);
// 查找账户 - 使用默认账户
const defaultAccount = financeStore.accounts.find(a => a.currency === (row.currency || row['币种'] || 'CNY'));
if (!defaultAccount) {
failCount++;
continue;
}
await financeStore.createTransaction({
type: typeValue,
amount: Number(row.amount || row['金额']),
currency: row.currency || row['币种'] || 'CNY',
categoryId: category?.id,
accountId: defaultAccount.id,
transactionDate: row.date || row['日期'],
description: row.description || row['描述'] || row['项目名称'] || ''
});
successCount++;
} catch (error) {
console.error('导入单条数据失败:', error);
failCount++;
}
}
notification.success({
message: '导入完成',
description: `成功导入 ${successCount} 条,失败 ${failCount}`
});
showImportModal.value = false;
importPreviewData.value = [];
} catch (error) {
console.error('导入失败:', error);
notification.error({
message: '导入失败',
description: '数据导入过程中出现错误'
});
}
};
onMounted(async () => {
await Promise.all([
financeStore.initializeData(),
financeStore.fetchTransactions(),
]);
});
</script>
<style scoped>
.grid { display: grid; }
</style>
</style>

View File

@@ -8,17 +8,6 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card title="🔧 基本设置">
<Form :model="settings" layout="vertical">
<Form.Item label="默认货币">
<Select v-model:value="settings.defaultCurrency" style="width: 100%" @change="saveCurrencySettings">
<Select.Option value="CNY">🇨🇳 人民币 (CNY)</Select.Option>
<Select.Option value="USD">🇺🇸 美元 (USD)</Select.Option>
<Select.Option value="EUR">🇪🇺 欧元 (EUR)</Select.Option>
<Select.Option value="JPY">🇯🇵 日元 (JPY)</Select.Option>
<Select.Option value="GBP">🇬🇧 英镑 (GBP)</Select.Option>
</Select>
</Form.Item>
<Divider>通知设置</Divider>
<div class="space-y-3">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,7 @@ export default defineConfig(async () => {
'/api': {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
// mock代理目标地址
target: 'http://localhost:5320/api',
target: 'http://localhost:3000/api',
ws: true,
},
},