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:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
95
apps/web-antd/scripts/add-category-to-csv.ts
Normal file
95
apps/web-antd/scripts/add-category-to-csv.ts
Normal 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} 条记录`);
|
||||
224
apps/web-antd/scripts/import-csv.ts
Normal file
224
apps/web-antd/scripts/import-csv.ts
Normal 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);
|
||||
281
apps/web-antd/src/api/core/finance.ts
Normal file
281
apps/web-antd/src/api/core/finance.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 ?? '';
|
||||
// 如果没有错误信息,则会根据状态码进行提示
|
||||
|
||||
@@ -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>
|
||||
|
||||
14
apps/web-antd/src/custom.css
Normal file
14
apps/web-antd/src/custom.css
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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, // 禁用折叠按钮
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
106
apps/web-antd/src/router/routes/modules/business-modules.ts
Normal file
106
apps/web-antd/src/router/routes/modules/business-modules.ts
Normal 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;
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
325
apps/web-antd/src/store/finance.ts
Normal file
325
apps/web-antd/src/store/finance.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
1038
apps/web-antd/src/views/finance/statistics/index.vue
Normal file
1038
apps/web-antd/src/views/finance/statistics/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user