feat: 更新财务系统功能和界面优化

- 优化财务仪表板数据展示
- 增强账户管理功能
- 改进预算和分类管理
- 完善报表和统计分析
- 优化交易管理界面
- 更新Workspace工作区

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
woshiqp465
2025-10-05 15:10:06 +08:00
parent a1dc8de7e5
commit 1def26f74f
35 changed files with 4449 additions and 3000 deletions

View File

@@ -32,7 +32,7 @@
<div id="app"></div>
<script>
// Flatten FinWise Pro menu - Remove parent menu and show children directly
(function() {
(function () {
console.log('[FinWise] Script loaded');
function flattenFinWiseProMenu() {
@@ -41,7 +41,7 @@
console.log('[FinWise] Found submenus:', submenus.length);
let finwiseMenu = null;
submenus.forEach(menu => {
submenus.forEach((menu) => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl && titleEl.textContent) {
console.log('[FinWise] Menu title:', titleEl.textContent.trim());
@@ -74,7 +74,7 @@
// Move all children to the parent menu
const children = Array.from(childrenUL.children);
console.log('[FinWise] Moving', children.length, 'children');
children.forEach(child => {
children.forEach((child) => {
parentMenu.insertBefore(child, finwiseMenu);
});
@@ -84,28 +84,30 @@
}
// Run after DOM loads
const delays = [500, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7000, 8000];
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() {
document.addEventListener('DOMContentLoaded', function () {
console.log('[FinWise] DOMContentLoaded fired');
delays.forEach(delay => {
delays.forEach((delay) => {
setTimeout(flattenFinWiseProMenu, delay);
});
});
} else {
console.log('[FinWise] DOM already loaded');
delays.forEach(delay => {
delays.forEach((delay) => {
setTimeout(flattenFinWiseProMenu, delay);
});
}
// Watch for DOM changes
setTimeout(function() {
setTimeout(function () {
console.log('[FinWise] Setting up MutationObserver');
const observer = new MutationObserver(function() {
const observer = new MutationObserver(function () {
setTimeout(flattenFinWiseProMenu, 200);
});
@@ -113,7 +115,7 @@
if (body) {
observer.observe(body, {
childList: true,
subtree: true
subtree: true,
});
console.log('[FinWise] MutationObserver active');
}

View File

@@ -1,14 +1,19 @@
import * as fs from 'node:fs';
const INPUT_CSV = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正.csv';
const OUTPUT_CSV = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
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|超鹏|小白/)) {
if (
desc.includes('工资') ||
/amy|天天|碧桂园|皇|香缇卡|财务|客服|小哥|代理ip|sy|超鹏|小白/.test(desc)
) {
return '工资';
}
@@ -23,22 +28,34 @@ function getCategory(project: string): string {
}
// 服务器/技术
if (desc.match(/服务器|技术|chatgpt|openai|ai|接口|ip|nat|宝塔|cdn|oss|google|翻译|openrouter|deepseek|claude|cursor|bolt|硅基|chatwoot/)) {
if (
/服务器|技术|chatgpt|openai|ai|接口|ip|nat|宝塔|cdn|oss|google|翻译|openrouter|deepseek|claude|cursor|bolt|硅基|chatwoot/.test(
desc,
)
) {
return '服务器/技术';
}
// 广告推广
if (desc.match(/广告|推广|地推|投放|打流量/)) {
if (/广告|推广|地推|投放|打流量/.test(desc)) {
return '广告推广';
}
// 软件/工具
if (desc.match(/会员|007|u盘|processon|飞机|虚拟卡|小红卡|信用卡|cloudflare|uizard|esim/)) {
if (
/会员|007|u盘|processon|飞机|虚拟卡|小红卡|信用卡|cloudflare|uizard|esim/.test(
desc,
)
) {
return '软件/工具';
}
// 固定资产
if (desc.match(/买车|电脑|笔记本|显示器|rog|硬盘|服务器.*购买|iphone|路由器|展示屏/)) {
if (
/买车|电脑|笔记本|显示器|rog|硬盘|服务器.*购买|iphone|路由器|展示屏/.test(
desc,
)
) {
return '固定资产';
}
@@ -48,7 +65,11 @@ function getCategory(project: string): string {
}
// 借款/转账
if (desc.match(/借|转给|龙腾|投资款|换.*铢|换美金|换现金|报销|房租|生活费|办公室|出差|接待|保关|测试|开工红包/)) {
if (
/借|转给|龙腾|投资款|换.*铢|换美金|换现金|报销|房租|生活费|办公室|出差|接待|保关|测试|开工红包/.test(
desc,
)
) {
return '借款/转账';
}
@@ -57,12 +78,12 @@ function getCategory(project: string): string {
}
// 读取并处理CSV
const content = fs.readFileSync(INPUT_CSV, 'utf-8');
const content = fs.readFileSync(INPUT_CSV, 'utf8');
const lines = content.split('\n');
// 修改表头,添加"分类"列
const header = lines[0];
const newHeader = header.trimEnd() + ',分类\n';
const newHeader = `${header.trimEnd()},分类\n`;
// 处理每一行数据
const newLines = [newHeader];
@@ -84,7 +105,7 @@ for (let i = 1; i < lines.length; i++) {
const category = getCategory(project);
// 添加分类列
const newLine = line.trimEnd() + ',' + category + '\n';
const newLine = `${line.trimEnd()},${category}\n`;
newLines.push(newLine);
}

View File

@@ -1,7 +1,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
const CSV_FILE = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
const CSV_FILE =
'/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
const API_URL = 'http://localhost:3000/api/finance/transactions';
interface CSVRow {
@@ -59,11 +59,7 @@ function parseDate(dateStr: string, previousDate: string = ''): string {
const prevMonth = Number.parseInt(previousDate.split('-')[1]);
// 如果月份从大变小例如12月->2月或7月->8月说明跨年了
if (month < prevMonth) {
year = prevYear + 1;
} else {
year = prevYear;
}
year = month < prevMonth ? prevYear + 1 : prevYear;
} else if (month >= 8) {
// 第一条记录8-12月是2024年
year = 2024;
@@ -85,11 +81,7 @@ function parseDate(dateStr: string, previousDate: string = ''): string {
const prevYear = Number.parseInt(previousDate.split('-')[0]);
const prevMonth = Number.parseInt(previousDate.split('-')[1]);
if (month < prevMonth) {
year = prevYear + 1;
} else {
year = prevYear;
}
year = month < prevMonth ? prevYear + 1 : prevYear;
} else if (month >= 8) {
year = 2024;
} else {
@@ -109,12 +101,12 @@ function parseAmount(amountStr: string): number {
const cleaned = amountStr.trim();
// 如果包含乘号(*或×或x先处理乘法
if (cleaned.match(/[*×x]/)) {
if (/[*×x]/.test(cleaned)) {
// 提取乘法表达式,如 "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]);
const num1 = Number.parseFloat(mulMatch[1]);
const num2 = Number.parseFloat(mulMatch[2]);
if (!isNaN(num1) && !isNaN(num2)) {
return num1 * num2;
}
@@ -126,7 +118,7 @@ function parseAmount(amountStr: string): number {
const parts = cleaned.split('+');
let sum = 0;
for (const part of parts) {
const num = parseFloat(part.replace(/[^\d.]/g, ''));
const num = Number.parseFloat(part.replaceAll(/[^\d.]/g, ''));
if (!isNaN(num)) {
sum += num;
}
@@ -135,22 +127,22 @@ function parseAmount(amountStr: string): number {
}
// 否则直接解析
return parseFloat(cleaned.replace(/[^\d.]/g, '')) || 0;
return Number.parseFloat(cleaned.replaceAll(/[^\d.]/g, '')) || 0;
}
// 根据分类名称获取分类ID
function getCategoryIdByName(categoryName: string): number {
const categoryMap: Record<string, number> = {
'工资': 5,
工资: 5,
'佣金/返佣': 6,
'分红': 7,
分红: 7,
'服务器/技术': 8,
'广告推广': 9,
广告推广: 9,
'软件/工具': 10,
'固定资产': 11,
'退款': 12,
固定资产: 11,
退款: 12,
'借款/转账': 13,
'其他支出': 14,
其他支出: 14,
};
return categoryMap[categoryName] || 2; // 默认未分类支出
@@ -158,7 +150,7 @@ function getCategoryIdByName(categoryName: string): number {
// 批量导入
async function importTransactions() {
const content = fs.readFileSync(CSV_FILE, 'utf-8');
const content = fs.readFileSync(CSV_FILE, 'utf8');
const rows = parseCSV(content);
console.log(`共解析到 ${rows.length} 条记录`);
@@ -202,14 +194,16 @@ async function importTransactions() {
if (response.ok) {
imported++;
console.log(`✓ 导入成功 [${imported}/${rows.length}]: ${row.project} - $${amount}`);
console.log(
`✓ 导入成功 [${imported}/${rows.length}]: ${row.project} - $${amount}`,
);
} else {
failed++;
console.error(`✗ 导入失败: ${row.project}`, await response.text());
}
// 避免请求过快
await new Promise(resolve => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
} catch (error) {
failed++;
console.error(`✗ 处理失败: ${row.project}`, error);

View File

@@ -13,9 +13,9 @@ export namespace FinanceApi {
// 分类
export interface Category {
id: number;
userId?: number | null;
userId?: null | number;
name: string;
type: 'income' | 'expense';
type: 'expense' | 'income';
icon: string;
color: string;
sortOrder?: number;
@@ -28,7 +28,14 @@ export namespace FinanceApi {
id: number;
userId?: number;
name: string;
type: 'cash' | 'bank' | 'alipay' | 'wechat' | 'virtual_wallet' | 'investment' | 'credit_card';
type:
| 'alipay'
| 'bank'
| 'cash'
| 'credit_card'
| 'investment'
| 'virtual_wallet'
| 'wechat';
currency: string;
balance?: number;
icon?: string;
@@ -43,20 +50,20 @@ export namespace FinanceApi {
toCurrency: string;
rate: number;
date: string;
source: 'manual' | 'api' | 'system';
source: 'api' | 'manual' | 'system';
}
// 交易
export interface Transaction {
id: number;
userId: number;
type: 'income' | 'expense' | 'transfer';
type: 'expense' | 'income' | 'transfer';
amount: number;
currency: string;
exchangeRateToBase: number;
amountInBase: number;
categoryId?: number | null;
accountId?: number | null;
categoryId?: null | number;
accountId?: null | number;
transactionDate: string;
description: string;
project?: string;
@@ -68,7 +75,7 @@ export namespace FinanceApi {
// 创建交易的参数
export interface CreateTransactionParams {
type: 'income' | 'expense' | 'transfer';
type: 'expense' | 'income' | 'transfer';
amount: number;
currency: string;
categoryId?: number;
@@ -92,7 +99,7 @@ export namespace FinanceApi {
remaining: number;
percentage: number;
currency: string;
period: 'monthly' | 'weekly' | 'quarterly' | 'yearly';
period: 'monthly' | 'quarterly' | 'weekly' | 'yearly';
alertThreshold: number;
description?: string;
autoRenew: boolean;
@@ -114,7 +121,7 @@ export namespace FinanceApi {
remaining?: number;
percentage?: number;
currency: string;
period: 'monthly' | 'weekly' | 'quarterly' | 'yearly';
period: 'monthly' | 'quarterly' | 'weekly' | 'yearly';
alertThreshold: number;
description?: string;
autoRenew: boolean;
@@ -133,7 +140,9 @@ export namespace FinanceApi {
/**
* 获取分类
*/
export async function getCategories(params?: { type?: 'income' | 'expense' | 'transfer' }) {
export async function getCategories(params?: {
type?: 'expense' | 'income' | 'transfer';
}) {
return requestClient.get<Category[]>('/finance/categories', { params });
}
@@ -141,10 +150,10 @@ export namespace FinanceApi {
* 创建分类
*/
export async function createCategory(data: {
name: string;
type: 'income' | 'expense';
icon?: string;
color?: string;
icon?: string;
name: string;
type: 'expense' | 'income';
}) {
return requestClient.post<Category | null>('/finance/categories', data);
}
@@ -155,13 +164,16 @@ export namespace FinanceApi {
export async function updateCategory(
id: number,
data: {
name?: string;
icon?: string;
color?: string;
icon?: string;
name?: string;
sortOrder?: number;
},
) {
return requestClient.put<Category | null>(`/finance/categories/${id}`, data);
return requestClient.put<Category | null>(
`/finance/categories/${id}`,
data,
);
}
/**
@@ -184,9 +196,9 @@ export namespace FinanceApi {
* 获取汇率
*/
export async function getExchangeRates(params?: {
date?: string;
from?: string;
to?: string;
date?: string;
}) {
return requestClient.get<ExchangeRate[]>('/finance/exchange-rates', {
params,
@@ -197,7 +209,7 @@ export namespace FinanceApi {
* 获取交易列表
*/
export async function getTransactions(params?: {
type?: 'income' | 'expense' | 'transfer';
type?: 'expense' | 'income' | 'transfer';
}) {
return requestClient.get<Transaction[]>('/finance/transactions', {
params,

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, onMounted, watch } from 'vue';
import { computed, onMounted } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences';
@@ -34,7 +34,7 @@ const flattenFinWiseProMenu = () => {
const submenus = document.querySelectorAll('.vben-sub-menu');
let finwiseMenu: Element | null = null;
submenus.forEach(menu => {
submenus.forEach((menu) => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl?.textContent?.includes('FinWise Pro')) {
finwiseMenu = menu;
@@ -49,16 +49,17 @@ const flattenFinWiseProMenu = () => {
if (!childrenUL || !parentMenu) return;
// Check if already processed
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true')
return;
// Move all children to the parent menu
const children = Array.from(childrenUL.children);
children.forEach(child => {
parentMenu.insertBefore(child, finwiseMenu);
const children = [...childrenUL.children];
children.forEach((child) => {
finwiseMenu.before(child);
});
// Mark for hiding via CSS and hide directly
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
(finwiseMenu as HTMLElement).dataset.hideFinwise = 'true';
(finwiseMenu as HTMLElement).style.display = 'none';
};
@@ -66,7 +67,12 @@ const flattenFinWiseProMenu = () => {
onMounted(() => {
// 强制修复sidebar设置防止被用户UI操作覆盖
const fixSidebarPreferences = () => {
const prefsKey = Object.keys(localStorage).find(k => k.includes('preferences') && !k.includes('locale') && !k.includes('theme'));
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) || '{}');
@@ -78,8 +84,8 @@ onMounted(() => {
prefs.value.sidebar.collapsedWidth = 230;
localStorage.setItem(prefsKey, JSON.stringify(prefs));
}
} catch(e) {
console.error('Failed to fix sidebar preferences:', e);
} catch (error) {
console.error('Failed to fix sidebar preferences:', error);
}
}
};
@@ -89,7 +95,7 @@ onMounted(() => {
// 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 => {
delays.forEach((delay) => {
setTimeout(flattenFinWiseProMenu, delay);
});
@@ -104,7 +110,7 @@ onMounted(() => {
if (body) {
observer.observe(body, {
childList: true,
subtree: true
subtree: true,
});
}
}, 100);
@@ -112,7 +118,9 @@ onMounted(() => {
// 防止侧边栏自动收起
setTimeout(() => {
const preventSidebarCollapse = () => {
const sidebar = document.querySelector('[class*="sidebar"]') || document.querySelector('aside');
const sidebar =
document.querySelector('[class*="sidebar"]') ||
document.querySelector('aside');
if (!sidebar) return;
@@ -120,7 +128,7 @@ onMounted(() => {
const sidebarObserver = new MutationObserver(() => {
const currentWidth = window.getComputedStyle(sidebar).width;
// 如果宽度小于200px说明可能被收起了强制恢复
if (parseInt(currentWidth) < 200) {
if (Number.parseInt(currentWidth) < 200) {
(sidebar as HTMLElement).style.width = '230px';
}
});
@@ -128,7 +136,7 @@ onMounted(() => {
// 开始观察
sidebarObserver.observe(sidebar, {
attributes: true,
attributeFilter: ['class', 'style']
attributeFilter: ['class', 'style'],
});
// 强制设置初始宽度

View File

@@ -1,5 +1,7 @@
/* Hide FinWise Pro parent menu and move children */
.vben-sub-menu:has(.vben-sub-menu-content__title:is(:contains("FinWise Pro"), :contains("💎"))) {
.vben-sub-menu:has(
.vben-sub-menu-content__title:is(:contains('FinWise Pro'), :contains('💎'))
) {
display: none !important;
}
@@ -9,6 +11,6 @@
}
/* Mark submenu for hiding */
.vben-sub-menu[data-hide-finwise="true"] {
.vben-sub-menu[data-hide-finwise='true'] {
display: none !important;
}

View File

@@ -2,6 +2,7 @@ import { initPreferences } from '@vben/preferences';
import { unmountGlobalLoading } from '@vben/utils';
import { overridesPreferences } from './preferences';
import './custom.css';
/**
@@ -36,7 +37,7 @@ function flattenFinWiseProMenu() {
const submenus = document.querySelectorAll('.vben-sub-menu');
let finwiseMenu: Element | null = null;
submenus.forEach(menu => {
submenus.forEach((menu) => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl?.textContent?.includes('FinWise Pro')) {
finwiseMenu = menu;
@@ -51,16 +52,17 @@ function flattenFinWiseProMenu() {
if (!childrenUL || !parentMenu) return;
// Check if already processed
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true')
return;
// Move all children to the parent menu
const children = Array.from(childrenUL.children);
children.forEach(child => {
parentMenu.insertBefore(child, finwiseMenu);
const children = [...childrenUL.children];
children.forEach((child) => {
finwiseMenu.before(child);
});
// Mark for hiding via CSS and hide directly
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
(finwiseMenu as HTMLElement).dataset.hideFinwise = 'true';
(finwiseMenu as HTMLElement).style.display = 'none';
}
@@ -91,7 +93,7 @@ setTimeout(() => {
if (body) {
observer.observe(body, {
childList: true,
subtree: true
subtree: true,
});
}
}, 500);

View File

@@ -40,7 +40,7 @@ router.afterEach(() => {
const submenus = document.querySelectorAll('.vben-sub-menu');
let finwiseMenu: Element | null = null;
submenus.forEach(menu => {
submenus.forEach((menu) => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl?.textContent?.includes('FinWise Pro')) {
finwiseMenu = menu;
@@ -55,16 +55,19 @@ router.afterEach(() => {
if (!childrenUL || !parentMenu) return;
// Check if already processed
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
if (
(finwiseMenu as HTMLElement).dataset.hideFinwise === 'true'
)
return;
// Move all children to the parent menu
const children = Array.from(childrenUL.children);
children.forEach(child => {
parentMenu.insertBefore(child, finwiseMenu);
const children = [...childrenUL.children];
children.forEach((child) => {
finwiseMenu.before(child);
});
// Mark for hiding via CSS and hide directly
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
(finwiseMenu as HTMLElement).dataset.hideFinwise = 'true';
(finwiseMenu as HTMLElement).style.display = 'none';
};

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { FinanceApi } from '#/api/core/finance';
export const useFinanceStore = defineStore('finance', () => {
@@ -50,10 +51,10 @@ export const useFinanceStore = defineStore('finance', () => {
// 创建分类
async function createCategory(data: {
name: string;
type: 'income' | 'expense';
icon?: string;
color?: string;
icon?: string;
name: string;
type: 'expense' | 'income';
}) {
const category = await FinanceApi.createCategory(data);
if (!category) {
@@ -71,9 +72,9 @@ export const useFinanceStore = defineStore('finance', () => {
async function updateCategory(
id: number,
data: {
name?: string;
icon?: string;
color?: string;
icon?: string;
name?: string;
sortOrder?: number;
},
) {
@@ -97,13 +98,13 @@ export const useFinanceStore = defineStore('finance', () => {
await FinanceApi.deleteCategory(id);
// 从本地列表中移除
let index = incomeCategories.value.findIndex((c) => c.id === id);
if (index !== -1) {
incomeCategories.value.splice(index, 1);
} else {
if (index === -1) {
index = expenseCategories.value.findIndex((c) => c.id === id);
if (index !== -1) {
expenseCategories.value.splice(index, 1);
}
} else {
incomeCategories.value.splice(index, 1);
}
}

View File

@@ -2,9 +2,9 @@
import type { VbenFormSchema } from '@vben/common-ui';
import type { BasicOption } from '@vben/types';
import { computed, markRaw } from 'vue';
import { computed } from 'vue';
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
import { AuthenticationLogin, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { useAuthStore } from '#/store';

View File

@@ -11,13 +11,17 @@ function handleBackHome() {
</script>
<template>
<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>
<div
class="bg-background flex h-screen w-screen flex-col items-center justify-center"
>
<h1 class="text-foreground mb-4 text-6xl font-bold">404</h1>
<h2 class="text-foreground mb-2 text-2xl font-semibold">
哎呀未找到页面
</h2>
<p class="text-muted-foreground mb-8">抱歉我们无法找到您要找的页面</p>
<button
@click="handleBackHome"
class="rounded-md bg-primary px-6 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-6 py-2 transition-colors"
>
返回首页
</button>

View File

@@ -21,7 +21,20 @@ 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 {
Button,
Col,
DatePicker,
Form,
Input,
InputNumber,
message,
Modal,
Radio,
Row,
Select,
Switch,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { useFinanceStore } from '#/store/finance';
@@ -38,7 +51,7 @@ onMounted(async () => {
// 快速记账弹窗
const quickAddVisible = ref(false);
const transactionType = ref<'income' | 'expense'>('expense');
const transactionType = ref<'expense' | 'income'>('expense');
const formRef = ref();
const formState = ref({
currency: 'CNY', // 默认人民币
@@ -57,7 +70,9 @@ const formState = ref({
const useQuantityMode = ref(false);
// 当前选中的日期类型
const selectedDateType = ref<'today' | 'yesterday' | 'week' | 'month' | 'custom'>('today');
const selectedDateType = ref<
'custom' | 'month' | 'today' | 'week' | 'yesterday'
>('today');
// 字段触摸状态(用于判断是否显示验证提示)
const touchedFields = ref({
@@ -67,11 +82,14 @@ const touchedFields = ref({
});
// 监听单价和数量变化,自动计算总金额
watch([() => formState.value.unitPrice, () => formState.value.quantity], ([unitPrice, quantity]) => {
if (useQuantityMode.value && unitPrice && quantity) {
formState.value.amount = unitPrice * quantity;
}
});
watch(
[() => formState.value.unitPrice, () => formState.value.quantity],
([unitPrice, quantity]) => {
if (useQuantityMode.value && unitPrice && quantity) {
formState.value.amount = unitPrice * quantity;
}
},
);
// 切换计算模式
const toggleQuantityMode = (enabled: boolean) => {
@@ -79,7 +97,8 @@ const toggleQuantityMode = (enabled: boolean) => {
if (enabled) {
// 如果当前有金额,反推单价
if (formState.value.amount && formState.value.quantity) {
formState.value.unitPrice = formState.value.amount / formState.value.quantity;
formState.value.unitPrice =
formState.value.amount / formState.value.quantity;
}
} else {
// 关闭模式时清空单价和数量
@@ -107,30 +126,38 @@ const currentCurrencySymbol = computed(() => {
});
// 监听货币变化,重置账户选择
watch(() => formState.value.currency, () => {
formState.value.account = undefined;
touchedFields.value.account = true; // 标记账户字段为已触摸
});
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));
}
});
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') => {
const openQuickAdd = (type: 'expense' | 'income') => {
transactionType.value = type;
quickAddVisible.value = true;
// 读取上次选择的账户
const storageKey = type === 'income'
? 'lastWorkspaceIncomeAccountId'
: 'lastWorkspaceExpenseAccountId';
const storageKey =
type === 'income'
? 'lastWorkspaceIncomeAccountId'
: 'lastWorkspaceExpenseAccountId';
const lastAccountId = localStorage.getItem(storageKey);
const accountId = lastAccountId ? Number(lastAccountId) : undefined;
@@ -160,54 +187,61 @@ const openQuickAdd = (type: 'income' | 'expense') => {
};
// 日期快捷方式
const setDate = (type: 'today' | 'yesterday' | 'week' | 'month') => {
const setDate = (type: 'month' | 'today' | 'week' | 'yesterday') => {
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':
case 'month': {
formState.value.date = dayjs().startOf('month');
break;
}
case 'today': {
formState.value.date = dayjs();
break;
}
case 'week': {
formState.value.date = dayjs().startOf('week');
break;
}
case 'yesterday': {
formState.value.date = dayjs().subtract(1, 'day');
break;
}
}
};
// 监听日期手动变化,设置为自定义
watch(() => formState.value.date, (newDate) => {
if (!newDate) return;
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');
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';
}
});
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', // 绿色 - 今天
today: '#52c41a', // 绿色 - 今天
yesterday: '#1890ff', // 蓝色 - 昨天
week: '#722ed1', // 紫色 - 本周
month: '#fa8c16', // 橙色 - 本月
custom: '#8c8c8c', // 灰色 - 自定义
week: '#722ed1', // 紫色 - 本周
month: '#fa8c16', // 橙色 - 本月
custom: '#8c8c8c', // 灰色 - 自定义
};
return colors[type] || colors.custom;
};
@@ -216,7 +250,9 @@ const getDateTypeColor = (type: string) => {
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),
amount:
touchedFields.value.amount &&
(!formState.value.amount || formState.value.amount <= 0),
}));
// 提交记账
@@ -246,7 +282,9 @@ const handleQuickAdd = async () => {
});
console.log('交易创建成功:', transaction);
message.success(`${transactionType.value === 'income' ? '收入' : '支出'}记录成功!`);
message.success(
`${transactionType.value === 'income' ? '收入' : '支出'}记录成功!`,
);
quickAddVisible.value = false;
// 重置表单
@@ -385,31 +423,31 @@ const todoItems = ref<WorkbenchTodoItem[]>([
{
completed: false,
content: `记录本月的水电费、房租等固定支出`,
date: new Date().toLocaleDateString() + ' 18:00:00',
date: `${new Date().toLocaleDateString()} 18:00:00`,
title: '录入本月固定支出',
},
{
completed: false,
content: `查看并调整各类别的预算设置,确保支出在可控范围内`,
date: new Date().toLocaleDateString() + ' 20:00:00',
date: `${new Date().toLocaleDateString()} 20:00:00`,
title: '检查月度预算执行情况',
},
{
completed: true,
content: `完成本周的收入记录,包括工资和其他收入来源`,
date: new Date().toLocaleDateString() + ' 10:00:00',
date: `${new Date().toLocaleDateString()} 10:00:00`,
title: '记录本周收入',
},
{
completed: false,
content: `核对银行账户余额,确保系统数据与实际一致`,
date: new Date().toLocaleDateString() + ' 15:00:00',
date: `${new Date().toLocaleDateString()} 15:00:00`,
title: '对账核对',
},
{
completed: false,
content: `分析上月的支出报表,找出可以节省开支的地方`,
date: new Date().toLocaleDateString() + ' 16:00:00',
date: `${new Date().toLocaleDateString()} 16:00:00`,
title: '生成月度财务报表',
},
]);
@@ -521,18 +559,28 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<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="(item) => {
console.log('WorkbenchQuickNav click事件触发:', item);
navTo(item);
}"
@click="
(item) => {
console.log('WorkbenchQuickNav click事件触发:', item);
navTo(item);
}
"
/>
<WorkbenchTodo :items="todoItems" class="mt-5" title="财务待办事项" />
<AnalysisChartCard class="mt-5" title="本月收支概览">
@@ -547,15 +595,18 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
:title="transactionType === 'income' ? '💰 添加收入' : '💸 添加支出'"
:width="900"
@ok="handleQuickAdd"
@cancel="() => { quickAddVisible = false; }"
@update:open="(val) => { quickAddVisible = val; }"
@cancel="
() => {
quickAddVisible = false;
}
"
@update:open="
(val) => {
quickAddVisible = val;
}
"
>
<Form
ref="formRef"
:model="formState"
layout="vertical"
class="mt-4"
>
<Form ref="formRef" :model="formState" layout="vertical" class="mt-4">
<Row :gutter="16">
<!-- 分类 -->
<Col :span="14">
@@ -567,7 +618,15 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
:help="fieldErrors.category ? '⚠️ 请选择一个分类' : ''"
>
<div
:style="fieldErrors.category ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '8px' } : {}"
:style="
fieldErrors.category
? {
border: '2px solid #ff4d4f',
borderRadius: '6px',
padding: '8px',
}
: {}
"
>
<Radio.Group
v-model:value="formState.category"
@@ -590,10 +649,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<!-- 项目名称 -->
<Col :span="10">
<Form.Item
label="项目名称"
name="description"
>
<Form.Item label="项目名称" name="description">
<Input.TextArea
v-model:value="formState.description"
placeholder="请输入项目名称..."
@@ -605,11 +661,13 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</Row>
<!-- 货币类型账户和金额放在一起 -->
<div class="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-4">
<div class="mb-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<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>
<label class="mb-2 block text-sm font-medium"
>货币类型 <span class="text-red-500">*</span></label
>
<Radio.Group
v-model:value="formState.currency"
size="large"
@@ -629,7 +687,10 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<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" />
<Switch
v-model:checked="useQuantityMode"
@change="toggleQuantityMode"
/>
</div>
</Col>
</Row>
@@ -637,7 +698,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<!-- 数量×单价模式 -->
<Row v-if="useQuantityMode" :gutter="16" class="mb-4">
<Col :span="8">
<label class="block text-sm font-medium mb-2">数量</label>
<label class="mb-2 block text-sm font-medium">数量</label>
<InputNumber
v-model:value="formState.quantity"
:min="0.01"
@@ -648,7 +709,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
/>
</Col>
<Col :span="8">
<label class="block text-sm font-medium mb-2">单价</label>
<label class="mb-2 block text-sm font-medium">单价</label>
<InputNumber
v-model:value="formState.unitPrice"
:min="0"
@@ -661,12 +722,24 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</InputNumber>
</Col>
<Col :span="8">
<label class="block text-sm font-medium mb-2">
<label class="mb-2 block text-sm font-medium">
总金额 <span class="text-red-500">*</span>
<span v-if="fieldErrors.amount" class="text-red-500 text-xs ml-1"></span>
<span
v-if="fieldErrors.amount"
class="ml-1 text-xs text-red-500"
></span
>
</label>
<div
:style="fieldErrors.amount ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '2px' } : {}"
:style="
fieldErrors.amount
? {
border: '2px solid #ff4d4f',
borderRadius: '6px',
padding: '2px',
}
: {}
"
>
<InputNumber
v-model:value="formState.amount"
@@ -687,12 +760,24 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<!-- 直接输入金额模式 -->
<Row v-else :gutter="16" class="mb-4">
<Col :span="24">
<label class="block text-sm font-medium mb-2">
<label class="mb-2 block text-sm font-medium">
金额 <span class="text-red-500">*</span>
<span v-if="fieldErrors.amount" class="text-red-500 text-xs ml-2"> 请输入金额</span>
<span
v-if="fieldErrors.amount"
class="ml-2 text-xs text-red-500"
> 请输入金额</span
>
</label>
<div
:style="fieldErrors.amount ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '2px' } : {}"
:style="
fieldErrors.amount
? {
border: '2px solid #ff4d4f',
borderRadius: '6px',
padding: '2px',
}
: {}
"
>
<InputNumber
v-model:value="formState.amount"
@@ -712,7 +797,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<!-- 重量可选 -->
<Row :gutter="16" class="mb-4">
<Col :span="16">
<label class="block text-sm font-medium mb-2">重量可选</label>
<label class="mb-2 block text-sm font-medium">重量可选</label>
<InputNumber
v-model:value="formState.weight"
:min="0"
@@ -722,7 +807,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
/>
</Col>
<Col :span="8">
<label class="block text-sm font-medium mb-2">单位</label>
<label class="mb-2 block text-sm font-medium">单位</label>
<Select v-model:value="formState.weightUnit" style="width: 100%">
<Select.Option value="kg">千克(kg)</Select.Option>
<Select.Option value="g">(g)</Select.Option>
@@ -733,12 +818,23 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</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 class="mb-2 block text-sm font-medium">
{{ transactionType === 'income' ? '收入账户' : '支出账户' }}
<span class="text-red-500">*</span>
<span v-if="fieldErrors.account" class="ml-2 text-xs text-red-500"
> 请选择账户</span
>
</label>
<div
:style="fieldErrors.account ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '8px' } : {}"
:style="
fieldErrors.account
? {
border: '2px solid #ff4d4f',
borderRadius: '6px',
padding: '8px',
}
: {}
"
>
<Radio.Group
v-model:value="formState.account"
@@ -766,15 +862,35 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<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 }"
: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 }"
:type="
selectedDateType === 'yesterday' ? 'primary' : 'default'
"
:style="{
backgroundColor:
selectedDateType === 'yesterday'
? getDateTypeColor('yesterday')
: undefined,
borderColor:
selectedDateType === 'yesterday'
? getDateTypeColor('yesterday')
: undefined,
}"
@click="setDate('yesterday')"
block
>
@@ -782,7 +898,16 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</Button>
<Button
:type="selectedDateType === 'week' ? 'primary' : 'default'"
:style="{ backgroundColor: selectedDateType === 'week' ? getDateTypeColor('week') : undefined, borderColor: selectedDateType === 'week' ? getDateTypeColor('week') : undefined }"
:style="{
backgroundColor:
selectedDateType === 'week'
? getDateTypeColor('week')
: undefined,
borderColor:
selectedDateType === 'week'
? getDateTypeColor('week')
: undefined,
}"
@click="setDate('week')"
block
>
@@ -790,7 +915,16 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</Button>
<Button
:type="selectedDateType === 'month' ? 'primary' : 'default'"
:style="{ backgroundColor: selectedDateType === 'month' ? getDateTypeColor('month') : undefined, borderColor: selectedDateType === 'month' ? getDateTypeColor('month') : undefined }"
:style="{
backgroundColor:
selectedDateType === 'month'
? getDateTypeColor('month')
: undefined,
borderColor:
selectedDateType === 'month'
? getDateTypeColor('month')
: undefined,
}"
@click="setDate('month')"
block
>
@@ -800,16 +934,13 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</Form.Item>
</Col>
<Col :span="14">
<Form.Item
label="选择日期"
name="date"
>
<Form.Item label="选择日期" name="date">
<div
class="date-picker-wrapper"
:style="{
border: `2px solid ${getDateTypeColor(selectedDateType)}`,
borderRadius: '6px',
padding: '4px'
padding: '4px',
}"
>
<DatePicker
@@ -846,10 +977,15 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
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) {
: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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,373 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import {
Button,
Card,
Col,
Dropdown,
Form,
Input,
InputNumber,
Menu,
Modal,
notification,
Progress,
Row,
Select,
Slider,
Switch,
Tag,
} from 'ant-design-vue';
import { useFinanceStore } from '#/store/finance';
defineOptions({ name: 'BudgetManagement' });
const financeStore = useFinanceStore();
const budgets = computed(() =>
financeStore.budgets.filter((b) => !b.isDeleted),
);
const showAddModal = ref(false);
const formRef = ref();
// 表单数据
const budgetForm = ref({
category: '',
customCategoryName: '',
customCategoryIcon: '',
limit: null,
currency: 'CNY',
customCurrencyCode: '',
customCurrencyName: '',
period: 'monthly',
alertThreshold: 80,
description: '',
autoRenew: true,
overspendAlert: true,
dailyReminder: false,
});
// 表单验证规则
const rules = {
category: [{ required: true, message: '请选择预算分类', trigger: 'change' }],
limit: [
{ required: true, message: '请输入预算金额', trigger: 'blur' },
{
type: 'number',
min: 0.01,
message: '预算金额必须大于0',
trigger: 'blur',
},
],
currency: [{ required: true, message: '请选择币种', trigger: 'change' }],
period: [{ required: true, message: '请选择预算周期', trigger: 'change' }],
};
// 计算属性
const totalBudget = computed(() => {
return budgets.value.reduce((sum, budget) => sum + budget.limit, 0);
});
const totalSpent = computed(() => {
return budgets.value.reduce((sum, budget) => sum + budget.spent, 0);
});
const totalRemaining = computed(() => {
return budgets.value.reduce((sum, budget) => sum + budget.remaining, 0);
});
const averageUsage = computed(() => {
if (budgets.value.length === 0) return 0;
return (
budgets.value.reduce((sum, budget) => sum + budget.percentage, 0) /
budgets.value.length
);
});
// 功能方法
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
}).format(amount);
};
const formatCurrencyWithCode = (amount: number, currencyCode: string) => {
// 如果是自定义币种(包含括号),直接显示数字 + 币种代码
if (currencyCode && currencyCode.includes('(')) {
return `${amount.toLocaleString()} ${currencyCode}`;
}
// 对于标准币种,使用格式化
try {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: currencyCode || 'CNY',
}).format(amount);
} catch {
// 如果币种代码不被支持,则直接显示数字 + 代码
return `${amount.toLocaleString()} ${currencyCode || 'CNY'}`;
}
};
const getProgressColor = (percentage: number) => {
if (percentage > 100) return '#ff4d4f';
if (percentage > 90) return '#faad14';
if (percentage > 75) return '#1890ff';
return '#52c41a';
};
const getAmountColor = (percentage: number) => {
if (percentage > 100) return 'text-red-600';
if (percentage > 90) return 'text-orange-600';
if (percentage > 75) return 'text-blue-600';
return 'text-green-600';
};
const getCategoryEmoji = (category: string) => {
const emojiMap = {
food: '🍽️',
transport: '🚗',
shopping: '🛒',
entertainment: '🎮',
medical: '🏥',
housing: '🏠',
education: '📚',
travel: '✈️',
};
return emojiMap[category] || '🎯';
};
const getCategoryName = (category: string) => {
const nameMap = {
food: '餐饮',
transport: '交通',
shopping: '购物',
entertainment: '娱乐',
medical: '医疗',
housing: '住房',
education: '教育',
travel: '旅游',
};
return nameMap[category] || category;
};
const openAddBudgetModal = () => {
showAddModal.value = true;
resetForm();
};
const submitBudget = async () => {
try {
// 表单验证
await formRef.value.validate();
// 处理自定义字段
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) {
notification.error({
message: '添加失败',
description: '该分类已存在预算设置',
});
return;
}
// 创建新预算
await financeStore.createBudget({
category: finalCategory,
emoji: finalEmoji,
limit: budgetForm.value.limit,
currency: finalCurrency,
spent: 0,
remaining: budgetForm.value.limit,
percentage: 0,
period: budgetForm.value.period,
alertThreshold: budgetForm.value.alertThreshold,
description: budgetForm.value.description,
autoRenew: budgetForm.value.autoRenew,
overspendAlert: budgetForm.value.overspendAlert,
dailyReminder: budgetForm.value.dailyReminder,
monthlyTrend: 0,
});
notification.success({
message: '预算设置成功',
description: `${finalCategory} 预算已成功创建`,
});
// 关闭模态框
showAddModal.value = false;
resetForm();
} catch (error) {
console.error('表单验证失败:', error);
notification.error({
message: '添加失败',
description: '请检查表单信息是否正确',
});
}
};
const cancelAdd = () => {
showAddModal.value = false;
resetForm();
};
const resetForm = () => {
budgetForm.value = {
category: '',
customCategoryName: '',
customCategoryIcon: '',
limit: null,
currency: 'CNY',
customCurrencyCode: '',
customCurrencyName: '',
period: 'monthly',
alertThreshold: 80,
description: '',
autoRenew: true,
overspendAlert: true,
dailyReminder: false,
};
};
const handleCategoryChange = (category: string) => {
console.log('分类选择:', category);
if (category !== 'CUSTOM') {
budgetForm.value.customCategoryName = '';
budgetForm.value.customCategoryIcon = '';
}
};
const handleCurrencyChange = (currency: string) => {
console.log('币种选择:', currency);
if (currency !== 'CUSTOM') {
budgetForm.value.customCurrencyCode = '';
budgetForm.value.customCurrencyName = '';
}
};
// 预算操作方法
const editBudget = (budget: any) => {
console.log('编辑预算:', budget);
notification.info({
message: '编辑预算',
description: `编辑 ${budget.category} 预算设置`,
});
};
const adjustBudget = (budget: any) => {
console.log('调整预算额度:', budget);
notification.info({
message: '调整额度',
description: `调整 ${budget.category} 预算额度`,
});
};
const viewHistory = (budget: any) => {
console.log('查看预算历史:', budget);
notification.info({
message: '历史记录',
description: `查看 ${budget.category} 预算历史`,
});
};
const deleteBudget = async (budget: any) => {
console.log('删除预算:', budget);
await financeStore.deleteBudget(budget.id);
notification.success({
message: '预算已删除',
description: `${budget.category} 预算已删除`,
});
};
onMounted(async () => {
await financeStore.fetchBudgets();
});
</script>
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">🎯 预算管理</h1>
<h1 class="mb-2 text-3xl font-bold text-gray-900">🎯 预算管理</h1>
<p class="text-gray-600">设置和监控各类别的预算执行情况</p>
</div>
<div v-if="budgets.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 mb-6">设置预算帮助您更好地控制支出</p>
<div v-if="budgets.length === 0" class="py-12 text-center">
<div class="mb-6 text-8xl">🎯</div>
<h3 class="mb-2 text-xl font-medium text-gray-800">暂无预算设置</h3>
<p class="mb-6 text-gray-500">设置预算帮助您更好地控制支出</p>
<Button type="primary" size="large" @click="openAddBudgetModal">
设置第一个预算
</Button>
</div>
<div v-else>
<!-- 预算概览统计 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl">💰</div>
<p class="text-sm text-gray-500">总预算</p>
<p class="text-xl font-bold text-blue-600">{{ formatCurrency(totalBudget) }}</p>
<p class="text-xl font-bold text-blue-600">
{{ formatCurrency(totalBudget) }}
</p>
</div>
</Card>
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl">📊</div>
<p class="text-sm text-gray-500">已使用</p>
<p class="text-xl font-bold text-orange-600">{{ formatCurrency(totalSpent) }}</p>
<p class="text-xl font-bold text-orange-600">
{{ formatCurrency(totalSpent) }}
</p>
</div>
</Card>
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl">🎯</div>
<p class="text-sm text-gray-500">剩余预算</p>
<p class="text-xl font-bold text-green-600">{{ formatCurrency(totalRemaining) }}</p>
<p class="text-xl font-bold text-green-600">
{{ formatCurrency(totalRemaining) }}
</p>
</div>
</Card>
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl"></div>
<p class="text-sm text-gray-500">执行率</p>
<p class="text-xl font-bold text-purple-600">{{ averageUsage.toFixed(1) }}%</p>
<p class="text-xl font-bold text-purple-600">
{{ averageUsage.toFixed(1) }}%
</p>
</div>
</Card>
</div>
<!-- 预算卡片列表 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
<Card v-for="budget in budgets" :key="budget.id" class="relative hover:shadow-lg transition-shadow">
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card
v-for="budget in budgets"
:key="budget.id"
class="relative transition-shadow hover:shadow-lg"
>
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
@@ -60,9 +378,18 @@
<template #overlay>
<Menu>
<Menu.Item @click="editBudget(budget)"> 编辑</Menu.Item>
<Menu.Item @click="adjustBudget(budget)">📊 调整额度</Menu.Item>
<Menu.Item @click="viewHistory(budget)">📈 历史记录</Menu.Item>
<Menu.Item @click="deleteBudget(budget)" class="text-red-600">🗑 删除</Menu.Item>
<Menu.Item @click="adjustBudget(budget)">
📊 调整额度
</Menu.Item>
<Menu.Item @click="viewHistory(budget)">
📈 历史记录
</Menu.Item>
<Menu.Item
@click="deleteBudget(budget)"
class="text-red-600"
>
🗑 删除
</Menu.Item>
</Menu>
</template>
<Button type="text" size="small"></Button>
@@ -73,22 +400,38 @@
<!-- 预算进度 -->
<div class="space-y-4">
<div class="text-center">
<p class="text-2xl font-bold" :class="getAmountColor(budget.percentage)">
{{ formatCurrencyWithCode(budget.spent, budget.currency) }} / {{ formatCurrencyWithCode(budget.limit, budget.currency) }}
<p
class="text-2xl font-bold"
:class="getAmountColor(budget.percentage)"
>
{{ formatCurrencyWithCode(budget.spent, budget.currency) }} /
{{ formatCurrencyWithCode(budget.limit, budget.currency) }}
</p>
<p class="text-sm text-gray-500">已用 / 预算</p>
</div>
<Progress
:percent="budget.percentage"
<Progress
:percent="budget.percentage"
:stroke-color="getProgressColor(budget.percentage)"
/>
<div class="flex justify-between text-sm">
<span :class="budget.remaining >= 0 ? 'text-green-600' : 'text-red-600'">
{{ budget.remaining >= 0 ? '剩余' : '超支' }}: {{ formatCurrencyWithCode(Math.abs(budget.remaining), budget.currency) }}
<span
:class="
budget.remaining >= 0 ? 'text-green-600' : 'text-red-600'
"
>
{{ budget.remaining >= 0 ? '剩余' : '超支' }}:
{{
formatCurrencyWithCode(
Math.abs(budget.remaining),
budget.currency,
)
}}
</span>
<span class="text-gray-500">{{ budget.percentage.toFixed(1) }}%</span>
<span class="text-gray-500"
>{{ budget.percentage.toFixed(1) }}%</span
>
</div>
<!-- 预算状态标签 -->
@@ -102,25 +445,32 @@
<Tag v-else-if="budget.percentage > 75" color="blue">
使用正常
</Tag>
<Tag v-else color="green">
控制良好
</Tag>
<Tag v-else color="green"> 控制良好 </Tag>
</div>
<!-- 月度趋势 -->
<div v-if="budget.monthlyTrend" class="text-center">
<p class="text-xs text-gray-500">相比上月</p>
<p :class="budget.monthlyTrend >= 0 ? 'text-red-500' : 'text-green-500'" class="font-medium">
{{ budget.monthlyTrend >= 0 ? '↗️' : '↘️' }} {{ Math.abs(budget.monthlyTrend).toFixed(1) }}%
<p
:class="
budget.monthlyTrend >= 0 ? 'text-red-500' : 'text-green-500'
"
class="font-medium"
>
{{ budget.monthlyTrend >= 0 ? '↗️' : '↘️' }}
{{ Math.abs(budget.monthlyTrend).toFixed(1) }}%
</p>
</div>
</div>
</Card>
<!-- 添加预算卡片 -->
<Card class="border-2 border-dashed border-gray-300 hover:border-blue-400 cursor-pointer transition-all" @click="openAddBudgetModal">
<div class="text-center py-12">
<div class="text-6xl mb-4"></div>
<Card
class="cursor-pointer border-2 border-dashed border-gray-300 transition-all hover:border-blue-400"
@click="openAddBudgetModal"
>
<div class="py-12 text-center">
<div class="mb-4 text-6xl"></div>
<h3 class="font-medium text-gray-800">添加新预算</h3>
<p class="text-sm text-gray-500">为分类设置预算控制</p>
</div>
@@ -129,16 +479,21 @@
</div>
<!-- 添加预算模态框 -->
<Modal
v-model:open="showAddModal"
title=" 设置新预算"
<Modal
v-model:open="showAddModal"
title=" 设置新预算"
@ok="submitBudget"
@cancel="cancelAdd"
width="500px"
>
<Form ref="formRef" :model="budgetForm" :rules="rules" layout="vertical">
<Form.Item label="预算分类" name="category" required>
<Select v-model:value="budgetForm.category" placeholder="选择分类" size="large" @change="handleCategoryChange">
<Select
v-model:value="budgetForm.category"
placeholder="选择分类"
size="large"
@change="handleCategoryChange"
>
<Select.Option value="food">🍽 餐饮</Select.Option>
<Select.Option value="transport">🚗 交通</Select.Option>
<Select.Option value="shopping">🛒 购物</Select.Option>
@@ -156,17 +511,23 @@
<Row :gutter="16">
<Col :span="12">
<Form.Item label="分类名称" required>
<Input v-model:value="budgetForm.customCategoryName" placeholder="请输入分类名称,如: 宝贝用品、理财等" />
<Input
v-model:value="budgetForm.customCategoryName"
placeholder="请输入分类名称,如: 宝贝用品、理财等"
/>
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="分类图标" required>
<Input v-model:value="budgetForm.customCategoryIcon" placeholder="请输入图标,如: 👶, 💹 等" />
<Input
v-model:value="budgetForm.customCategoryIcon"
placeholder="请输入图标,如: 👶, 💹 等"
/>
</Form.Item>
</Col>
</Row>
</div>
<Row :gutter="16">
<Col :span="8">
<Form.Item label="预算金额" name="limit" required>
@@ -182,7 +543,12 @@
</Col>
<Col :span="8">
<Form.Item label="金额币种" name="currency" required>
<Select v-model:value="budgetForm.currency" placeholder="选择币种" size="large" @change="handleCurrencyChange">
<Select
v-model:value="budgetForm.currency"
placeholder="选择币种"
size="large"
@change="handleCurrencyChange"
>
<Select.Option value="CNY">🇨🇳 人民币</Select.Option>
<Select.Option value="USD">🇺🇸 美元</Select.Option>
<Select.Option value="EUR">🇪🇺 欧元</Select.Option>
@@ -211,12 +577,19 @@
<Row :gutter="16">
<Col :span="12">
<Form.Item label="币种代码" required>
<Input v-model:value="budgetForm.customCurrencyCode" placeholder="如: THB, AUD 等" style="text-transform: uppercase" />
<Input
v-model:value="budgetForm.customCurrencyCode"
placeholder="如: THB, AUD 等"
style="text-transform: uppercase"
/>
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="币种名称" required>
<Input v-model:value="budgetForm.customCurrencyName" placeholder="如: 泰铢, 澳元 等" />
<Input
v-model:value="budgetForm.customCurrencyName"
placeholder="如: 泰铢, 澳元 等"
/>
</Form.Item>
</Col>
</Row>
@@ -231,29 +604,31 @@
:step="5"
:marks="{ 50: '50%', 75: '75%', 90: '90%', 100: '100%' }"
/>
<p class="text-sm text-gray-500">当支出达到预算的 {{ budgetForm.alertThreshold }}% 时发出预警</p>
<p class="text-sm text-gray-500">
当支出达到预算的 {{ budgetForm.alertThreshold }}% 时发出预警
</p>
</div>
</Form.Item>
<Form.Item label="预算描述">
<Input.TextArea
v-model:value="budgetForm.description"
:rows="3"
<Input.TextArea
v-model:value="budgetForm.description"
:rows="3"
placeholder="预算用途和目标..."
/>
</Form.Item>
<Form.Item label="预算设置">
<div class="space-y-3">
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<span>自动续期</span>
<Switch v-model:checked="budgetForm.autoRenew" />
</div>
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<span>超支提醒</span>
<Switch v-model:checked="budgetForm.overspendAlert" />
</div>
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<span>每日提醒</span>
<Switch v-model:checked="budgetForm.dailyReminder" />
</div>
@@ -264,289 +639,8 @@
</div>
</template>
<script setup lang="ts">
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 financeStore = useFinanceStore();
const budgets = computed(() => financeStore.budgets.filter(b => !b.isDeleted));
const showAddModal = ref(false);
const formRef = ref();
// 表单数据
const budgetForm = ref({
category: '',
customCategoryName: '',
customCategoryIcon: '',
limit: null,
currency: 'CNY',
customCurrencyCode: '',
customCurrencyName: '',
period: 'monthly',
alertThreshold: 80,
description: '',
autoRenew: true,
overspendAlert: true,
dailyReminder: false
});
// 表单验证规则
const rules = {
category: [
{ required: true, message: '请选择预算分类', trigger: 'change' }
],
limit: [
{ required: true, message: '请输入预算金额', trigger: 'blur' },
{ type: 'number', min: 0.01, message: '预算金额必须大于0', trigger: 'blur' }
],
currency: [
{ required: true, message: '请选择币种', trigger: 'change' }
],
period: [
{ required: true, message: '请选择预算周期', trigger: 'change' }
]
};
// 计算属性
const totalBudget = computed(() => {
return budgets.value.reduce((sum, budget) => sum + budget.limit, 0);
});
const totalSpent = computed(() => {
return budgets.value.reduce((sum, budget) => sum + budget.spent, 0);
});
const totalRemaining = computed(() => {
return budgets.value.reduce((sum, budget) => sum + budget.remaining, 0);
});
const averageUsage = computed(() => {
if (budgets.value.length === 0) return 0;
return budgets.value.reduce((sum, budget) => sum + budget.percentage, 0) / budgets.value.length;
});
// 功能方法
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(amount);
};
const formatCurrencyWithCode = (amount: number, currencyCode: string) => {
// 如果是自定义币种(包含括号),直接显示数字 + 币种代码
if (currencyCode && currencyCode.includes('(')) {
return `${amount.toLocaleString()} ${currencyCode}`;
}
// 对于标准币种,使用格式化
try {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: currencyCode || 'CNY'
}).format(amount);
} catch {
// 如果币种代码不被支持,则直接显示数字 + 代码
return `${amount.toLocaleString()} ${currencyCode || 'CNY'}`;
}
};
const getProgressColor = (percentage: number) => {
if (percentage > 100) return '#ff4d4f';
if (percentage > 90) return '#faad14';
if (percentage > 75) return '#1890ff';
return '#52c41a';
};
const getAmountColor = (percentage: number) => {
if (percentage > 100) return 'text-red-600';
if (percentage > 90) return 'text-orange-600';
if (percentage > 75) return 'text-blue-600';
return 'text-green-600';
};
const getCategoryEmoji = (category: string) => {
const emojiMap = {
'food': '🍽️',
'transport': '🚗',
'shopping': '🛒',
'entertainment': '🎮',
'medical': '🏥',
'housing': '🏠',
'education': '📚',
'travel': '✈️'
};
return emojiMap[category] || '🎯';
};
const getCategoryName = (category: string) => {
const nameMap = {
'food': '餐饮',
'transport': '交通',
'shopping': '购物',
'entertainment': '娱乐',
'medical': '医疗',
'housing': '住房',
'education': '教育',
'travel': '旅游'
};
return nameMap[category] || category;
};
const openAddBudgetModal = () => {
showAddModal.value = true;
resetForm();
};
const submitBudget = async () => {
try {
// 表单验证
await formRef.value.validate();
// 处理自定义字段
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) {
notification.error({
message: '添加失败',
description: '该分类已存在预算设置'
});
return;
}
// 创建新预算
await financeStore.createBudget({
category: finalCategory,
emoji: finalEmoji,
limit: budgetForm.value.limit,
currency: finalCurrency,
spent: 0,
remaining: budgetForm.value.limit,
percentage: 0,
period: budgetForm.value.period,
alertThreshold: budgetForm.value.alertThreshold,
description: budgetForm.value.description,
autoRenew: budgetForm.value.autoRenew,
overspendAlert: budgetForm.value.overspendAlert,
dailyReminder: budgetForm.value.dailyReminder,
monthlyTrend: 0,
});
notification.success({
message: '预算设置成功',
description: `${finalCategory} 预算已成功创建`
});
// 关闭模态框
showAddModal.value = false;
resetForm();
} catch (error) {
console.error('表单验证失败:', error);
notification.error({
message: '添加失败',
description: '请检查表单信息是否正确'
});
}
};
const cancelAdd = () => {
showAddModal.value = false;
resetForm();
};
const resetForm = () => {
budgetForm.value = {
category: '',
customCategoryName: '',
customCategoryIcon: '',
limit: null,
currency: 'CNY',
customCurrencyCode: '',
customCurrencyName: '',
period: 'monthly',
alertThreshold: 80,
description: '',
autoRenew: true,
overspendAlert: true,
dailyReminder: false
};
};
const handleCategoryChange = (category: string) => {
console.log('分类选择:', category);
if (category !== 'CUSTOM') {
budgetForm.value.customCategoryName = '';
budgetForm.value.customCategoryIcon = '';
}
};
const handleCurrencyChange = (currency: string) => {
console.log('币种选择:', currency);
if (currency !== 'CUSTOM') {
budgetForm.value.customCurrencyCode = '';
budgetForm.value.customCurrencyName = '';
}
};
// 预算操作方法
const editBudget = (budget: any) => {
console.log('编辑预算:', budget);
notification.info({
message: '编辑预算',
description: `编辑 ${budget.category} 预算设置`
});
};
const adjustBudget = (budget: any) => {
console.log('调整预算额度:', budget);
notification.info({
message: '调整额度',
description: `调整 ${budget.category} 预算额度`
});
};
const viewHistory = (budget: any) => {
console.log('查看预算历史:', budget);
notification.info({
message: '历史记录',
description: `查看 ${budget.category} 预算历史`
});
};
const deleteBudget = async (budget: any) => {
console.log('删除预算:', budget);
await financeStore.deleteBudget(budget.id);
notification.success({
message: '预算已删除',
description: `${budget.category} 预算已删除`
});
};
onMounted(async () => {
await financeStore.fetchBudgets();
});
</script>
<style scoped>
.grid { display: grid; }
</style>
.grid {
display: grid;
}
</style>

View File

@@ -1,300 +1,18 @@
<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>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card title="📁 分类树结构">
<div v-if="categories.length === 0" class="text-center py-8">
<div class="text-6xl mb-4">🏷</div>
<p class="text-gray-500 mb-4">暂无分类数据</p>
<Button type="primary" @click="openAddCategoryModal"> 添加分类</Button>
</div>
<div v-else class="space-y-3">
<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-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 v-if="category.isSystem" color="blue" size="small">系统分类</Tag>
</div>
</div>
</div>
<div class="text-right">
<div class="space-x-2">
<Button type="link" size="small" @click="editCategory(category)"> 编辑</Button>
<Button type="link" size="small" danger @click="deleteCategory(category)" :disabled="category.isSystem">🗑 删除</Button>
</div>
</div>
</div>
</div>
</div>
</Card>
<Card title="📊 分类统计">
<div v-if="categories.length === 0" 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>
</div>
</div>
<div v-else class="space-y-4">
<!-- 分类统计数据 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="text-center p-3 bg-blue-50 rounded-lg">
<p class="text-sm text-gray-500">总分类数</p>
<p class="text-xl font-bold text-blue-600">{{ categoryStats.total }}</p>
</div>
<div class="text-center p-3 bg-green-50 rounded-lg">
<p class="text-sm text-gray-500">收入分类</p>
<p class="text-xl font-bold text-green-600">{{ categoryStats.income }}</p>
</div>
<div class="text-center p-3 bg-red-50 rounded-lg">
<p class="text-sm text-gray-500">支出分类</p>
<p class="text-xl font-bold text-red-600">{{ categoryStats.expense }}</p>
</div>
<div class="text-center p-3 bg-purple-50 rounded-lg">
<p class="text-sm text-gray-500">预算总额</p>
<p class="text-xl font-bold text-purple-600">¥{{ categoryStats.budgetTotal.toLocaleString() }}</p>
</div>
</div>
<!-- 分类列表 -->
<div class="space-y-2">
<h4 class="font-medium">📈 收入分类</h4>
<div class="space-y-2">
<div v-for="category in incomeCategories" :key="category.id"
class="flex items-center justify-between p-2 bg-green-50 rounded">
<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">
暂无收入分类
</div>
</div>
<h4 class="font-medium mt-4">📉 支出分类</h4>
<div class="space-y-2">
<div v-for="category in expenseCategories" :key="category.id"
class="flex items-center justify-between p-2 bg-red-50 rounded">
<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">
暂无支出分类
</div>
</div>
</div>
</div>
</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=" 添加新分类"
@ok="submitCategory"
@cancel="cancelAdd"
width="500px"
>
<Form ref="formRef" :model="categoryForm" :rules="rules" layout="vertical">
<Form.Item label="分类名称" name="name" required>
<Input
v-model:value="categoryForm.name"
placeholder="请输入分类名称,如:餐饮、交通等"
size="large"
/>
</Form.Item>
<Row :gutter="16">
<Col :span="12">
<Form.Item label="分类类型" name="type" required>
<Select v-model:value="categoryForm.type" placeholder="选择类型" size="large">
<Select.Option value="income">
<span>📈 收入分类</span>
</Select.Option>
<Select.Option value="expense">
<span>📉 支出分类</span>
</Select.Option>
</Select>
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="图标" name="icon">
<Select v-model:value="categoryForm.icon" placeholder="选择图标" size="large" @change="handleIconChange">
<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>
</Col>
</Row>
<!-- 自定义图标输入 -->
<div v-if="categoryForm.icon === 'CUSTOM'" class="mb-4">
<Form.Item label="自定义图标" required>
<Input v-model:value="categoryForm.customIcon" placeholder="请输入一个表情符号,如: 🍕, 🎬, 📚 等" />
</Form.Item>
</div>
<Row :gutter="16">
<Col :span="12">
<Form.Item label="月度预算" name="budget">
<InputNumber
v-model:value="categoryForm.budget"
:precision="2"
style="width: 100%"
placeholder="0.00"
:min="0"
/>
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="预算币种" name="budgetCurrency">
<Select v-model:value="categoryForm.budgetCurrency" placeholder="选择币种" size="large" @change="handleBudgetCurrencyChange">
<Select.Option value="CNY">🇨🇳 人民币</Select.Option>
<Select.Option value="USD">🇺🇸 美元</Select.Option>
<Select.Option value="EUR">🇪🇺 欧元</Select.Option>
<Select.Option value="JPY">🇯🇵 日元</Select.Option>
<Select.Option value="GBP">🇬🇧 英镑</Select.Option>
<Select.Option value="HKD">🇭🇰 港币</Select.Option>
<Select.Option value="KRW">🇰🇷 韩元</Select.Option>
<Select.Option value="CUSTOM"> 自定义币种</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<!-- 自定义币种输入 -->
<div v-if="categoryForm.budgetCurrency === 'CUSTOM'" class="mb-4">
<Row :gutter="16">
<Col :span="12">
<Form.Item label="币种代码" required>
<Input v-model:value="categoryForm.customCurrencyCode" placeholder="如: THB, AUD 等" style="text-transform: uppercase" />
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="币种名称" required>
<Input v-model:value="categoryForm.customCurrencyName" placeholder="如: 泰铢, 澳元 等" />
</Form.Item>
</Col>
</Row>
</div>
<Form.Item label="分类描述">
<Input.TextArea
v-model:value="categoryForm.description"
:rows="3"
placeholder="分类用途描述..."
/>
</Form.Item>
<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="categoryForm.color === color ? 'border-gray-800 scale-110' : 'border-gray-300'"
:style="{ backgroundColor: color }"
@click="categoryForm.color = color"
></div>
</div>
</Form.Item>
</Form>
</Modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
Card, Tag, Button, Modal, Form, Input, Select, Row, Col,
InputNumber, notification
Button,
Card,
Col,
Form,
Input,
InputNumber,
Modal,
notification,
Row,
Select,
Tag,
} from 'ant-design-vue';
import { useFinanceStore } from '#/store/finance';
@@ -329,7 +47,7 @@ const categoryForm = ref({
customCurrencyCode: '',
customCurrencyName: '',
description: '',
color: '#1890ff'
color: '#1890ff',
});
// 编辑表单数据
@@ -337,46 +55,56 @@ const editForm = ref({
name: '',
icon: '🏷️',
customIcon: '',
color: '#1890ff'
color: '#1890ff',
});
// 分类颜色选项
const categoryColors = ref([
'#1890ff', '#52c41a', '#fa541c', '#722ed1', '#eb2f96', '#13c2c2',
'#f5222d', '#fa8c16', '#fadb14', '#a0d911', '#36cfc9', '#b37feb'
'#1890ff',
'#52c41a',
'#fa541c',
'#722ed1',
'#eb2f96',
'#13c2c2',
'#f5222d',
'#fa8c16',
'#fadb14',
'#a0d911',
'#36cfc9',
'#b37feb',
]);
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 2, max: 20, message: '分类名称长度在2-20个字符', trigger: 'blur' }
{ min: 2, max: 20, message: '分类名称长度在2-20个字符', trigger: 'blur' },
],
type: [
{ required: true, message: '请选择分类类型', trigger: 'change' }
]
type: [{ required: true, message: '请选择分类类型', trigger: 'change' }],
};
// 计算统计
const categoryStats = computed(() => {
const incomeCategories = categories.value.filter(c => c.type === 'income');
const expenseCategories = categories.value.filter(c => c.type === 'expense');
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: 0 // 预算功能待实现
budgetTotal: 0, // 预算功能待实现
};
});
// 分类分组
const incomeCategories = computed(() => {
return categories.value.filter(c => c.type === 'income');
return categories.value.filter((c) => c.type === 'income');
});
const expenseCategories = computed(() => {
return categories.value.filter(c => c.type === 'expense');
return categories.value.filter((c) => c.type === 'expense');
});
// 功能方法
@@ -391,9 +119,10 @@ const submitCategory = async () => {
await formRef.value.validate();
// 处理自定义图标
const finalIcon = categoryForm.value.icon === 'CUSTOM'
? categoryForm.value.customIcon
: categoryForm.value.icon;
const finalIcon =
categoryForm.value.icon === 'CUSTOM'
? categoryForm.value.customIcon
: categoryForm.value.icon;
// 调用 store 创建分类
await financeStore.createCategory({
@@ -405,18 +134,17 @@ const submitCategory = async () => {
notification.success({
message: '分类添加成功',
description: `分类 "${categoryForm.value.name}" 已成功创建`
description: `分类 "${categoryForm.value.name}" 已成功创建`,
});
// 关闭模态框
showAddModal.value = false;
resetForm();
} catch (error) {
console.error('创建分类失败:', error);
notification.error({
message: '添加失败',
description: '请检查表单信息是否正确'
description: '请检查表单信息是否正确',
});
}
};
@@ -437,7 +165,7 @@ const resetForm = () => {
customCurrencyCode: '',
customCurrencyName: '',
description: '',
color: '#1890ff'
color: '#1890ff',
};
};
@@ -472,9 +200,10 @@ const submitEditCategory = async () => {
await editFormRef.value?.validate();
// 处理自定义图标
const finalIcon = editForm.value.icon === 'CUSTOM'
? editForm.value.customIcon
: editForm.value.icon;
const finalIcon =
editForm.value.icon === 'CUSTOM'
? editForm.value.customIcon
: editForm.value.icon;
// 调用 store 更新分类
await financeStore.updateCategory(editingCategory.value.id, {
@@ -485,17 +214,16 @@ const submitEditCategory = async () => {
notification.success({
message: '分类更新成功',
description: `分类 "${editForm.value.name}" 已更新`
description: `分类 "${editForm.value.name}" 已更新`,
});
showEditModal.value = false;
editingCategory.value = null;
} catch (error) {
console.error('更新分类失败:', error);
notification.error({
message: '更新失败',
description: '请检查表单信息是否正确'
description: '请检查表单信息是否正确',
});
}
};
@@ -505,7 +233,7 @@ const deleteCategory = (category: any) => {
if (category.isSystem) {
notification.warning({
message: '无法删除',
description: '系统分类不允许删除'
description: '系统分类不允许删除',
});
return;
}
@@ -521,16 +249,16 @@ const deleteCategory = (category: any) => {
await financeStore.deleteCategory(category.id);
notification.success({
message: '分类已删除',
description: `分类 "${category.name}" 已删除`
description: `分类 "${category.name}" 已删除`,
});
} catch (error) {
console.error('删除分类失败:', error);
notification.error({
message: '删除失败',
description: '删除分类时出错,请稍后重试'
description: '删除分类时出错,请稍后重试',
});
}
}
},
});
};
@@ -538,11 +266,406 @@ const setBudget = (category: any) => {
console.log('设置预算:', category);
notification.info({
message: '预算设置',
description: `为分类 "${category.name}" 设置预算`
description: `为分类 "${category.name}" 设置预算`,
});
};
</script>
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="mb-2 text-3xl font-bold text-gray-900">🏷 分类管理</h1>
<p class="text-gray-600">管理收支分类支持层级结构</p>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card title="📁 分类树结构">
<div v-if="categories.length === 0" class="py-8 text-center">
<div class="mb-4 text-6xl">🏷</div>
<p class="mb-4 text-gray-500">暂无分类数据</p>
<Button type="primary" @click="openAddCategoryModal">
添加分类
</Button>
</div>
<div v-else class="space-y-3">
<div
v-for="category in categories"
:key="category.id"
class="rounded-lg border p-4 transition-shadow hover:shadow-md"
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<span class="text-2xl">{{ category.icon }}</span>
<div>
<span class="text-lg font-medium">{{ category.name }}</span>
<div class="mt-1 flex items-center space-x-2">
<Tag
:color="category.type === 'income' ? 'green' : 'red'"
size="small"
>
{{ category.type === 'income' ? '📈 收入' : '📉 支出' }}
</Tag>
<Tag v-if="category.isSystem" color="blue" size="small">
系统分类
</Tag>
</div>
</div>
</div>
<div class="text-right">
<div class="space-x-2">
<Button
type="link"
size="small"
@click="editCategory(category)"
>
编辑
</Button>
<Button
type="link"
size="small"
danger
@click="deleteCategory(category)"
:disabled="category.isSystem"
>
🗑 删除
</Button>
</div>
</div>
</div>
</div>
</div>
</Card>
<Card title="📊 分类统计">
<div
v-if="categories.length === 0"
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
>
<div class="text-center">
<div class="mb-2 text-4xl">📈</div>
<p class="text-gray-600">添加分类后查看统计</p>
</div>
</div>
<div v-else class="space-y-4">
<!-- 分类统计数据 -->
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div class="rounded-lg bg-blue-50 p-3 text-center">
<p class="text-sm text-gray-500">总分类数</p>
<p class="text-xl font-bold text-blue-600">
{{ categoryStats.total }}
</p>
</div>
<div class="rounded-lg bg-green-50 p-3 text-center">
<p class="text-sm text-gray-500">收入分类</p>
<p class="text-xl font-bold text-green-600">
{{ categoryStats.income }}
</p>
</div>
<div class="rounded-lg bg-red-50 p-3 text-center">
<p class="text-sm text-gray-500">支出分类</p>
<p class="text-xl font-bold text-red-600">
{{ categoryStats.expense }}
</p>
</div>
<div class="rounded-lg bg-purple-50 p-3 text-center">
<p class="text-sm text-gray-500">预算总额</p>
<p class="text-xl font-bold text-purple-600">
¥{{ categoryStats.budgetTotal.toLocaleString() }}
</p>
</div>
</div>
<!-- 分类列表 -->
<div class="space-y-2">
<h4 class="font-medium">📈 收入分类</h4>
<div class="space-y-2">
<div
v-for="category in incomeCategories"
:key="category.id"
class="flex items-center justify-between rounded bg-green-50 p-2"
>
<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="py-2 text-center text-gray-500"
>
暂无收入分类
</div>
</div>
<h4 class="mt-4 font-medium">📉 支出分类</h4>
<div class="space-y-2">
<div
v-for="category in expenseCategories"
:key="category.id"
class="flex items-center justify-between rounded bg-red-50 p-2"
>
<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="py-2 text-center text-gray-500"
>
暂无支出分类
</div>
</div>
</div>
</div>
</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="h-8 w-8 cursor-pointer rounded-full border-2 transition-all hover:scale-110"
:class="
editForm.color === color
? 'scale-110 border-gray-800'
: 'border-gray-300'
"
:style="{ backgroundColor: color }"
@click="editForm.color = color"
></div>
</div>
</Form.Item>
</Form>
</Modal>
<!-- 添加分类模态框 -->
<Modal
v-model:open="showAddModal"
title=" 添加新分类"
@ok="submitCategory"
@cancel="cancelAdd"
width="500px"
>
<Form
ref="formRef"
:model="categoryForm"
:rules="rules"
layout="vertical"
>
<Form.Item label="分类名称" name="name" required>
<Input
v-model:value="categoryForm.name"
placeholder="请输入分类名称,如:餐饮、交通等"
size="large"
/>
</Form.Item>
<Row :gutter="16">
<Col :span="12">
<Form.Item label="分类类型" name="type" required>
<Select
v-model:value="categoryForm.type"
placeholder="选择类型"
size="large"
>
<Select.Option value="income">
<span>📈 收入分类</span>
</Select.Option>
<Select.Option value="expense">
<span>📉 支出分类</span>
</Select.Option>
</Select>
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="图标" name="icon">
<Select
v-model:value="categoryForm.icon"
placeholder="选择图标"
size="large"
@change="handleIconChange"
>
<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>
</Col>
</Row>
<!-- 自定义图标输入 -->
<div v-if="categoryForm.icon === 'CUSTOM'" class="mb-4">
<Form.Item label="自定义图标" required>
<Input
v-model:value="categoryForm.customIcon"
placeholder="请输入一个表情符号,如: 🍕, 🎬, 📚 等"
/>
</Form.Item>
</div>
<Row :gutter="16">
<Col :span="12">
<Form.Item label="月度预算" name="budget">
<InputNumber
v-model:value="categoryForm.budget"
:precision="2"
style="width: 100%"
placeholder="0.00"
:min="0"
/>
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="预算币种" name="budgetCurrency">
<Select
v-model:value="categoryForm.budgetCurrency"
placeholder="选择币种"
size="large"
@change="handleBudgetCurrencyChange"
>
<Select.Option value="CNY">🇨🇳 人民币</Select.Option>
<Select.Option value="USD">🇺🇸 美元</Select.Option>
<Select.Option value="EUR">🇪🇺 欧元</Select.Option>
<Select.Option value="JPY">🇯🇵 日元</Select.Option>
<Select.Option value="GBP">🇬🇧 英镑</Select.Option>
<Select.Option value="HKD">🇭🇰 港币</Select.Option>
<Select.Option value="KRW">🇰🇷 韩元</Select.Option>
<Select.Option value="CUSTOM"> 自定义币种</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<!-- 自定义币种输入 -->
<div v-if="categoryForm.budgetCurrency === 'CUSTOM'" class="mb-4">
<Row :gutter="16">
<Col :span="12">
<Form.Item label="币种代码" required>
<Input
v-model:value="categoryForm.customCurrencyCode"
placeholder="如: THB, AUD 等"
style="text-transform: uppercase"
/>
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="币种名称" required>
<Input
v-model:value="categoryForm.customCurrencyName"
placeholder="如: 泰铢, 澳元 等"
/>
</Form.Item>
</Col>
</Row>
</div>
<Form.Item label="分类描述">
<Input.TextArea
v-model:value="categoryForm.description"
:rows="3"
placeholder="分类用途描述..."
/>
</Form.Item>
<Form.Item label="分类颜色">
<div class="flex space-x-2">
<div
v-for="color in categoryColors"
:key="color"
class="h-8 w-8 cursor-pointer rounded-full border-2 transition-all hover:scale-110"
:class="
categoryForm.color === color
? 'scale-110 border-gray-800'
: 'border-gray-300'
"
:style="{ backgroundColor: color }"
@click="categoryForm.color = color"
></div>
</div>
</Form.Item>
</Form>
</Modal>
</div>
</template>
<style scoped>
.grid { display: grid; }
</style>
.grid {
display: grid;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +1,406 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import {
Button,
Card,
Divider,
Form,
Modal,
notification,
Switch,
Tag,
} from 'ant-design-vue';
defineOptions({ name: 'FinanceSettings' });
// 系统设置
const settings = ref({
defaultCurrency: 'CNY',
notifications: {
budget: true,
bills: true,
investment: false,
},
autoBackup: true,
compactMode: false,
autoLock: false,
analytics: true,
});
// 操作加载状态
const operationLoading = ref({
backup: false,
import: false,
cache: false,
reset: false,
});
// 功能方法
const saveCurrencySettings = (currency: string) => {
console.log('货币设置更改为:', currency);
localStorage.setItem('app-currency', currency);
notification.success({
message: '货币设置已更新',
description: `默认货币已设置为 ${currency}`,
});
};
const saveNotificationSettings = () => {
console.log('通知设置已保存:', settings.value.notifications);
localStorage.setItem(
'app-notifications',
JSON.stringify(settings.value.notifications),
);
notification.info({
message: '通知设置已保存',
description: '通知偏好设置已更新',
});
};
const toggleAutoBackup = (enabled: boolean) => {
console.log('自动备份:', enabled);
localStorage.setItem('app-auto-backup', enabled.toString());
notification.info({
message: enabled ? '自动备份已启用' : '自动备份已禁用',
description: enabled ? '系统将定期自动备份数据' : '已关闭自动备份功能',
});
};
const toggleCompactMode = (enabled: boolean) => {
console.log('紧凑模式:', enabled);
document.documentElement.classList.toggle('compact', enabled);
localStorage.setItem('app-compact-mode', enabled.toString());
notification.info({
message: enabled ? '紧凑模式已启用' : '紧凑模式已禁用',
});
};
const toggleAutoLock = (enabled: boolean) => {
console.log('自动锁屏:', enabled);
localStorage.setItem('app-auto-lock', enabled.toString());
notification.info({
message: enabled ? '自动锁屏已启用' : '自动锁屏已禁用',
});
};
const toggleAnalytics = (enabled: boolean) => {
console.log('数据统计:', enabled);
localStorage.setItem('app-analytics', enabled.toString());
notification.info({
message: enabled ? '数据统计已启用' : '数据统计已禁用',
});
};
const backupData = async () => {
operationLoading.value.backup = true;
try {
// 模拟备份过程
await new Promise((resolve) => setTimeout(resolve, 2000));
// 创建备份数据
const backupData = {
settings: settings.value,
timestamp: new Date().toISOString(),
version: '1.0.0',
};
// 下载备份文件
const blob = new Blob([JSON.stringify(backupData, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `finwise-pro-backup-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
notification.success({
message: '数据备份成功',
description: '备份文件已下载到本地',
});
} catch {
notification.error({
message: '备份失败',
description: '数据备份过程中出现错误',
});
} finally {
operationLoading.value.backup = false;
}
};
const importData = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
operationLoading.value.import = true;
try {
const text = await file.text();
const importedData = JSON.parse(text);
// 验证数据格式
if (importedData.settings && importedData.version) {
settings.value = { ...settings.value, ...importedData.settings };
notification.success({
message: '数据导入成功',
description: '设置已从备份文件恢复',
});
} else {
throw new Error('无效的备份文件格式');
}
} catch {
notification.error({
message: '导入失败',
description: '备份文件格式无效或已损坏',
});
} finally {
operationLoading.value.import = false;
}
}
});
input.click();
};
const clearCache = async () => {
operationLoading.value.cache = true;
try {
// 模拟清除缓存过程
await new Promise((resolve) => setTimeout(resolve, 1500));
// 清除各种缓存
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
}
// 清除localStorage中的缓存数据
const keysToKeep = new Set(['app-currency', 'app-language', 'app-theme']);
Object.keys(localStorage).forEach((key) => {
if (!keysToKeep.has(key)) {
localStorage.removeItem(key);
}
});
notification.success({
message: '缓存清除成功',
description: '系统缓存已清理完成',
});
} catch {
notification.error({
message: '清除失败',
description: '缓存清除过程中出现错误',
});
} finally {
operationLoading.value.cache = false;
}
};
const resetSystem = () => {
Modal.confirm({
title: '⚠️ 确认重置系统',
content: '此操作将删除所有数据和设置,且不可恢复。确定要继续吗?',
okText: '确定重置',
okType: 'danger',
cancelText: '取消',
async onOk() {
operationLoading.value.reset = true;
try {
// 模拟重置过程
await new Promise((resolve) => setTimeout(resolve, 2000));
// 清除所有本地数据
localStorage.clear();
sessionStorage.clear();
notification.success({
message: '系统重置成功',
description: '系统将重新加载以应用重置',
});
// 延迟重新加载
setTimeout(() => {
window.location.reload();
}, 2000);
} catch {
notification.error({
message: '重置失败',
description: '系统重置过程中出现错误',
});
} finally {
operationLoading.value.reset = false;
}
},
});
};
const saveAllSettings = () => {
console.log('保存所有设置:', settings.value);
localStorage.setItem('app-all-settings', JSON.stringify(settings.value));
notification.success({
message: '设置保存成功',
description: '所有配置已保存',
});
};
const resetAllSettings = () => {
settings.value = {
defaultCurrency: 'CNY',
notifications: {
budget: true,
bills: true,
investment: false,
},
autoBackup: true,
compactMode: false,
autoLock: false,
analytics: true,
};
notification.success({
message: '设置已重置',
description: '所有设置已恢复为默认值',
});
};
const exportAllSettings = () => {
const settingsData = {
settings: settings.value,
timestamp: new Date().toISOString(),
version: '1.0.0',
};
const blob = new Blob([JSON.stringify(settingsData, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `finwise-pro-settings-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
notification.success({
message: '设置导出成功',
description: '配置文件已下载',
});
};
// 初始化
onMounted(() => {
// 从localStorage恢复设置
try {
const savedSettings = localStorage.getItem('app-all-settings');
if (savedSettings) {
const parsed = JSON.parse(savedSettings);
settings.value = { ...settings.value, ...parsed };
}
settings.value.defaultCurrency =
localStorage.getItem('app-currency') || 'CNY';
} catch (error) {
console.error('设置恢复失败:', error);
}
console.log('系统设置页面加载完成');
});
</script>
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2"> 系统设置</h1>
<h1 class="mb-2 text-3xl font-bold text-gray-900"> 系统设置</h1>
<p class="text-gray-600">财务系统的个性化配置和偏好设置</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card title="🔧 基本设置">
<Form :model="settings" layout="vertical">
<Divider>通知设置</Divider>
<div class="space-y-3">
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<div>
<span class="font-medium">💰 预算提醒</span>
<p class="text-sm text-gray-500">预算接近或超支时提醒</p>
</div>
<Switch v-model:checked="settings.notifications.budget" @change="saveNotificationSettings" />
<Switch
v-model:checked="settings.notifications.budget"
@change="saveNotificationSettings"
/>
</div>
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<div>
<span class="font-medium">🔔 账单提醒</span>
<p class="text-sm text-gray-500">账单到期前提醒缴费</p>
</div>
<Switch v-model:checked="settings.notifications.bills" @change="saveNotificationSettings" />
<Switch
v-model:checked="settings.notifications.bills"
@change="saveNotificationSettings"
/>
</div>
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<div>
<span class="font-medium">📊 投资更新</span>
<p class="text-sm text-gray-500">投资收益变化通知</p>
</div>
<Switch v-model:checked="settings.notifications.investment" @change="saveNotificationSettings" />
<Switch
v-model:checked="settings.notifications.investment"
@change="saveNotificationSettings"
/>
</div>
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<div>
<span class="font-medium">💾 自动备份</span>
<p class="text-sm text-gray-500">定期自动备份数据</p>
</div>
<Switch v-model:checked="settings.autoBackup" @change="toggleAutoBackup" />
<Switch
v-model:checked="settings.autoBackup"
@change="toggleAutoBackup"
/>
</div>
</div>
<Divider>高级设置</Divider>
<div class="space-y-3">
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<span>🎨 紧凑模式</span>
<Switch v-model:checked="settings.compactMode" @change="toggleCompactMode" />
<Switch
v-model:checked="settings.compactMode"
@change="toggleCompactMode"
/>
</div>
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<span>🔒 自动锁屏</span>
<Switch v-model:checked="settings.autoLock" @change="toggleAutoLock" />
<Switch
v-model:checked="settings.autoLock"
@change="toggleAutoLock"
/>
</div>
<div class="flex justify-between items-center">
<div class="flex items-center justify-between">
<span>📈 数据统计</span>
<Switch v-model:checked="settings.analytics" @change="toggleAnalytics" />
<Switch
v-model:checked="settings.analytics"
@change="toggleAnalytics"
/>
</div>
</div>
<div class="mt-6 space-x-4">
<Button type="primary" @click="saveAllSettings">💾 保存所有设置</Button>
<Button type="primary" @click="saveAllSettings">
💾 保存所有设置
</Button>
<Button @click="resetAllSettings">🔄 恢复默认</Button>
<Button @click="exportAllSettings">📤 导出配置</Button>
</div>
</Form>
</Card>
<Card title="📊 系统状态">
<div class="space-y-3">
<div class="flex justify-between">
@@ -95,7 +430,12 @@
<Button block @click="clearCache" :loading="operationLoading.cache">
🧹 清除缓存
</Button>
<Button block danger @click="resetSystem" :loading="operationLoading.reset">
<Button
block
danger
@click="resetSystem"
:loading="operationLoading.reset"
>
🗑 重置系统
</Button>
</div>
@@ -104,306 +444,8 @@
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import {
Card, Select, Switch, Tag, Button, Form,
Divider, notification, Modal
} from 'ant-design-vue';
defineOptions({ name: 'FinanceSettings' });
// 系统设置
const settings = ref({
defaultCurrency: 'CNY',
notifications: {
budget: true,
bills: true,
investment: false
},
autoBackup: true,
compactMode: false,
autoLock: false,
analytics: true
});
// 操作加载状态
const operationLoading = ref({
backup: false,
import: false,
cache: false,
reset: false
});
// 功能方法
const saveCurrencySettings = (currency: string) => {
console.log('货币设置更改为:', currency);
localStorage.setItem('app-currency', currency);
notification.success({
message: '货币设置已更新',
description: `默认货币已设置为 ${currency}`
});
};
const saveNotificationSettings = () => {
console.log('通知设置已保存:', settings.value.notifications);
localStorage.setItem('app-notifications', JSON.stringify(settings.value.notifications));
notification.info({
message: '通知设置已保存',
description: '通知偏好设置已更新'
});
};
const toggleAutoBackup = (enabled: boolean) => {
console.log('自动备份:', enabled);
localStorage.setItem('app-auto-backup', enabled.toString());
notification.info({
message: enabled ? '自动备份已启用' : '自动备份已禁用',
description: enabled ? '系统将定期自动备份数据' : '已关闭自动备份功能'
});
};
const toggleCompactMode = (enabled: boolean) => {
console.log('紧凑模式:', enabled);
document.documentElement.classList.toggle('compact', enabled);
localStorage.setItem('app-compact-mode', enabled.toString());
notification.info({
message: enabled ? '紧凑模式已启用' : '紧凑模式已禁用'
});
};
const toggleAutoLock = (enabled: boolean) => {
console.log('自动锁屏:', enabled);
localStorage.setItem('app-auto-lock', enabled.toString());
notification.info({
message: enabled ? '自动锁屏已启用' : '自动锁屏已禁用'
});
};
const toggleAnalytics = (enabled: boolean) => {
console.log('数据统计:', enabled);
localStorage.setItem('app-analytics', enabled.toString());
notification.info({
message: enabled ? '数据统计已启用' : '数据统计已禁用'
});
};
const backupData = async () => {
operationLoading.value.backup = true;
try {
// 模拟备份过程
await new Promise(resolve => setTimeout(resolve, 2000));
// 创建备份数据
const backupData = {
settings: settings.value,
timestamp: new Date().toISOString(),
version: '1.0.0'
};
// 下载备份文件
const blob = new Blob([JSON.stringify(backupData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `finwise-pro-backup-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
notification.success({
message: '数据备份成功',
description: '备份文件已下载到本地'
});
} catch (error) {
notification.error({
message: '备份失败',
description: '数据备份过程中出现错误'
});
} finally {
operationLoading.value.backup = false;
}
};
const importData = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
operationLoading.value.import = true;
try {
const text = await file.text();
const importedData = JSON.parse(text);
// 验证数据格式
if (importedData.settings && importedData.version) {
settings.value = { ...settings.value, ...importedData.settings };
notification.success({
message: '数据导入成功',
description: '设置已从备份文件恢复'
});
} else {
throw new Error('无效的备份文件格式');
}
} catch (error) {
notification.error({
message: '导入失败',
description: '备份文件格式无效或已损坏'
});
} finally {
operationLoading.value.import = false;
}
}
};
input.click();
};
const clearCache = async () => {
operationLoading.value.cache = true;
try {
// 模拟清除缓存过程
await new Promise(resolve => setTimeout(resolve, 1500));
// 清除各种缓存
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
}
// 清除localStorage中的缓存数据
const keysToKeep = ['app-language', 'app-theme', 'app-currency'];
Object.keys(localStorage).forEach(key => {
if (!keysToKeep.includes(key)) {
localStorage.removeItem(key);
}
});
notification.success({
message: '缓存清除成功',
description: '系统缓存已清理完成'
});
} catch (error) {
notification.error({
message: '清除失败',
description: '缓存清除过程中出现错误'
});
} finally {
operationLoading.value.cache = false;
}
};
const resetSystem = () => {
Modal.confirm({
title: '⚠️ 确认重置系统',
content: '此操作将删除所有数据和设置,且不可恢复。确定要继续吗?',
okText: '确定重置',
okType: 'danger',
cancelText: '取消',
async onOk() {
operationLoading.value.reset = true;
try {
// 模拟重置过程
await new Promise(resolve => setTimeout(resolve, 2000));
// 清除所有本地数据
localStorage.clear();
sessionStorage.clear();
notification.success({
message: '系统重置成功',
description: '系统将重新加载以应用重置'
});
// 延迟重新加载
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (error) {
notification.error({
message: '重置失败',
description: '系统重置过程中出现错误'
});
} finally {
operationLoading.value.reset = false;
}
}
});
};
const saveAllSettings = () => {
console.log('保存所有设置:', settings.value);
localStorage.setItem('app-all-settings', JSON.stringify(settings.value));
notification.success({
message: '设置保存成功',
description: '所有配置已保存'
});
};
const resetAllSettings = () => {
settings.value = {
defaultCurrency: 'CNY',
notifications: {
budget: true,
bills: true,
investment: false
},
autoBackup: true,
compactMode: false,
autoLock: false,
analytics: true
};
notification.success({
message: '设置已重置',
description: '所有设置已恢复为默认值'
});
};
const exportAllSettings = () => {
const settingsData = {
settings: settings.value,
timestamp: new Date().toISOString(),
version: '1.0.0'
};
const blob = new Blob([JSON.stringify(settingsData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `finwise-pro-settings-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
notification.success({
message: '设置导出成功',
description: '配置文件已下载'
});
};
// 初始化
onMounted(() => {
// 从localStorage恢复设置
try {
const savedSettings = localStorage.getItem('app-all-settings');
if (savedSettings) {
const parsed = JSON.parse(savedSettings);
settings.value = { ...settings.value, ...parsed };
}
settings.value.defaultCurrency = localStorage.getItem('app-currency') || 'CNY';
} catch (error) {
console.error('设置恢复失败:', error);
}
console.log('系统设置页面加载完成');
});
</script>
<style scoped>
.grid { display: grid; }
</style>
.grid {
display: grid;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1398,7 +1398,9 @@ const _handleAccountChange = (account: string) => {
<div class="flex space-x-2">
<Button @click="openRecycleBin">
🗑 回收站
<span v-if="deletedTransactions.length > 0" class="ml-1">({{ deletedTransactions.length }})</span>
<span v-if="deletedTransactions.length > 0" class="ml-1"
>({{ deletedTransactions.length }})</span
>
</Button>
<Button type="primary" @click="quickAddIncome"> 💰 添加收入 </Button>
<Button @click="quickAddExpense"> 💸 添加支出 </Button>
@@ -1526,7 +1528,9 @@ const _handleAccountChange = (account: string) => {
<Row :gutter="16">
<Col :span="12">
<div class="mb-4">
<label class="mb-2 block text-sm font-medium">货币类型 <span class="text-red-500">*</span></label>
<label class="mb-2 block text-sm font-medium"
>货币类型 <span class="text-red-500">*</span></label
>
<Radio.Group
v-model:value="quickIncomeForm.currency"
size="large"
@@ -1581,7 +1585,9 @@ const _handleAccountChange = (account: string) => {
</InputNumber>
</Col>
<Col :span="8">
<label class="mb-2 block text-sm font-medium">总金额 <span class="text-red-500">*</span></label>
<label class="mb-2 block text-sm font-medium"
>总金额 <span class="text-red-500">*</span></label
>
<InputNumber
v-model:value="quickIncomeForm.amount"
:min="0"
@@ -1599,7 +1605,9 @@ const _handleAccountChange = (account: string) => {
<!-- 直接输入金额模式 -->
<Row v-else :gutter="16" class="mb-4">
<Col :span="24">
<label class="mb-2 block text-sm font-medium">金额 <span class="text-red-500">*</span></label>
<label class="mb-2 block text-sm font-medium"
>金额 <span class="text-red-500">*</span></label
>
<InputNumber
v-model:value="quickIncomeForm.amount"
:min="0"
@@ -1640,7 +1648,9 @@ const _handleAccountChange = (account: string) => {
</Row>
<div>
<label class="mb-2 block text-sm font-medium">收入账户 <span class="text-red-500">*</span></label>
<label class="mb-2 block text-sm font-medium"
>收入账户 <span class="text-red-500">*</span></label
>
<Radio.Group
v-model:value="quickIncomeForm.accountId"
size="large"
@@ -2382,7 +2392,9 @@ const _handleAccountChange = (account: string) => {
</Select>
</div>
<div>
<label class="mb-1 block text-sm font-medium">币种字段可选默认USD</label>
<label class="mb-1 block text-sm font-medium"
>币种字段可选默认USD</label
>
<Select
v-model:value="importMapping.currency"
placeholder="选择对应列"

View File

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