refactor: 整合财务系统到主应用并重构后端架构
主要变更: - 将独立的 web-finance 应用整合到 web-antd 主应用中 - 重命名 backend-mock 为 backend,增强后端功能 - 新增财务模块 API 端点(账户、预算、类别、交易) - 增强财务仪表板和报表功能 - 添加 SQLite 数据存储支持和财务数据导入脚本 - 优化路由结构,删除冗余的 finance-system 模块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2
.gitignore
vendored
@@ -15,6 +15,8 @@ coverage
|
|||||||
**/.vitepress/cache
|
**/.vitepress/cache
|
||||||
.cache
|
.cache
|
||||||
.turbo
|
.turbo
|
||||||
|
.vercel
|
||||||
|
storage/
|
||||||
.temp
|
.temp
|
||||||
dev-dist
|
dev-dist
|
||||||
.stylelintcache
|
.stylelintcache
|
||||||
|
|||||||
@@ -1,535 +0,0 @@
|
|||||||
export interface UserInfo {
|
|
||||||
id: number;
|
|
||||||
password: string;
|
|
||||||
realName: string;
|
|
||||||
roles: string[];
|
|
||||||
username: string;
|
|
||||||
homePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MOCK_USERS: UserInfo[] = [
|
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
password: '123456',
|
|
||||||
realName: 'Vben',
|
|
||||||
roles: ['super'],
|
|
||||||
username: 'vben',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
password: '123456',
|
|
||||||
realName: 'Admin',
|
|
||||||
roles: ['admin'],
|
|
||||||
username: 'admin',
|
|
||||||
homePath: '/workspace',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
password: '123456',
|
|
||||||
realName: 'Jack',
|
|
||||||
roles: ['user'],
|
|
||||||
username: 'jack',
|
|
||||||
homePath: '/analytics',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const MOCK_CODES = [
|
|
||||||
// super
|
|
||||||
{
|
|
||||||
codes: ['AC_100100', 'AC_100110', 'AC_100120', 'AC_100010'],
|
|
||||||
username: 'vben',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// admin
|
|
||||||
codes: ['AC_100010', 'AC_100020', 'AC_100030'],
|
|
||||||
username: 'admin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// user
|
|
||||||
codes: ['AC_1000001', 'AC_1000002'],
|
|
||||||
username: 'jack',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const dashboardMenus = [
|
|
||||||
{
|
|
||||||
meta: {
|
|
||||||
order: -1,
|
|
||||||
title: 'page.dashboard.title',
|
|
||||||
},
|
|
||||||
name: 'Dashboard',
|
|
||||||
path: '/dashboard',
|
|
||||||
redirect: '/workspace',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'Workspace',
|
|
||||||
path: '/workspace',
|
|
||||||
component: '/dashboard/workspace/index',
|
|
||||||
meta: {
|
|
||||||
affixTab: true,
|
|
||||||
title: 'page.dashboard.workspace',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const analyticsMenus = [
|
|
||||||
{
|
|
||||||
meta: {
|
|
||||||
order: 2,
|
|
||||||
title: '数据分析',
|
|
||||||
icon: 'ant-design:bar-chart-outlined',
|
|
||||||
},
|
|
||||||
name: 'Analytics',
|
|
||||||
path: '/analytics',
|
|
||||||
redirect: '/analytics/overview',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'AnalyticsOverview',
|
|
||||||
path: '/analytics/overview',
|
|
||||||
component: '/analytics/overview/index',
|
|
||||||
meta: {
|
|
||||||
title: '数据概览',
|
|
||||||
icon: 'ant-design:dashboard-outlined',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AnalyticsTrends',
|
|
||||||
path: '/analytics/trends',
|
|
||||||
component: '/analytics/trends/index',
|
|
||||||
meta: {
|
|
||||||
title: '趋势分析',
|
|
||||||
icon: 'ant-design:line-chart-outlined',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AnalyticsReports',
|
|
||||||
path: '/analytics/reports',
|
|
||||||
meta: {
|
|
||||||
title: '报表',
|
|
||||||
icon: 'ant-design:file-text-outlined',
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'DailyReport',
|
|
||||||
path: '/analytics/reports/daily',
|
|
||||||
component: '/analytics/reports/daily',
|
|
||||||
meta: {
|
|
||||||
title: '日报表',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'MonthlyReport',
|
|
||||||
path: '/analytics/reports/monthly',
|
|
||||||
component: '/analytics/reports/monthly',
|
|
||||||
meta: {
|
|
||||||
title: '月报表',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'YearlyReport',
|
|
||||||
path: '/analytics/reports/yearly',
|
|
||||||
component: '/analytics/reports/yearly',
|
|
||||||
meta: {
|
|
||||||
title: '年报表',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'CustomReport',
|
|
||||||
path: '/analytics/reports/custom',
|
|
||||||
component: '/analytics/reports/custom',
|
|
||||||
meta: {
|
|
||||||
title: '自定义报表',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const financeMenus = [
|
|
||||||
{
|
|
||||||
meta: {
|
|
||||||
order: 3,
|
|
||||||
title: '财务管理',
|
|
||||||
icon: 'ant-design:dollar-circle-outlined',
|
|
||||||
},
|
|
||||||
name: 'Finance',
|
|
||||||
path: '/finance',
|
|
||||||
redirect: '/finance/dashboard',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'FinanceDashboard',
|
|
||||||
path: '/finance/dashboard',
|
|
||||||
component: '/finance/dashboard/index',
|
|
||||||
meta: {
|
|
||||||
title: '财务仪表盘',
|
|
||||||
icon: 'ant-design:dashboard-outlined',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FinanceTransaction',
|
|
||||||
path: '/finance/transaction',
|
|
||||||
component: '/finance/transaction/index',
|
|
||||||
meta: {
|
|
||||||
title: '交易管理',
|
|
||||||
icon: 'ant-design:transaction-outlined',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FinanceCategory',
|
|
||||||
path: '/finance/category',
|
|
||||||
component: '/finance/category/index',
|
|
||||||
meta: {
|
|
||||||
title: '分类管理',
|
|
||||||
icon: 'ant-design:appstore-outlined',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FinancePerson',
|
|
||||||
path: '/finance/person',
|
|
||||||
component: '/finance/person/index',
|
|
||||||
meta: {
|
|
||||||
title: '人员管理',
|
|
||||||
icon: 'ant-design:user-outlined',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FinanceLoan',
|
|
||||||
path: '/finance/loan',
|
|
||||||
component: '/finance/loan/index',
|
|
||||||
meta: {
|
|
||||||
title: '贷款管理',
|
|
||||||
icon: 'ant-design:bank-outlined',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FinanceBudget',
|
|
||||||
path: '/finance/budget',
|
|
||||||
component: '/finance/budget/index',
|
|
||||||
meta: {
|
|
||||||
title: '预算管理',
|
|
||||||
icon: 'ant-design:wallet-outlined',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FinanceTag',
|
|
||||||
path: '/finance/tag',
|
|
||||||
component: '/finance/tag/index',
|
|
||||||
meta: {
|
|
||||||
title: '标签管理',
|
|
||||||
icon: 'ant-design:tags-outlined',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const createDemosMenus = (role: 'admin' | 'super' | 'user') => {
|
|
||||||
const roleWithMenus = {
|
|
||||||
admin: {
|
|
||||||
component: '/demos/access/admin-visible',
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:button-cursor',
|
|
||||||
title: 'demos.access.adminVisible',
|
|
||||||
},
|
|
||||||
name: 'AccessAdminVisibleDemo',
|
|
||||||
path: '/demos/access/admin-visible',
|
|
||||||
},
|
|
||||||
super: {
|
|
||||||
component: '/demos/access/super-visible',
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:button-cursor',
|
|
||||||
title: 'demos.access.superVisible',
|
|
||||||
},
|
|
||||||
name: 'AccessSuperVisibleDemo',
|
|
||||||
path: '/demos/access/super-visible',
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
component: '/demos/access/user-visible',
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:button-cursor',
|
|
||||||
title: 'demos.access.userVisible',
|
|
||||||
},
|
|
||||||
name: 'AccessUserVisibleDemo',
|
|
||||||
path: '/demos/access/user-visible',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
meta: {
|
|
||||||
icon: 'ic:baseline-view-in-ar',
|
|
||||||
keepAlive: true,
|
|
||||||
order: 1000,
|
|
||||||
title: 'demos.title',
|
|
||||||
},
|
|
||||||
name: 'Demos',
|
|
||||||
path: '/demos',
|
|
||||||
redirect: '/demos/access',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'AccessDemos',
|
|
||||||
path: '/demosaccess',
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:cloud-key-outline',
|
|
||||||
title: 'demos.access.backendPermissions',
|
|
||||||
},
|
|
||||||
redirect: '/demos/access/page-control',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'AccessPageControlDemo',
|
|
||||||
path: '/demos/access/page-control',
|
|
||||||
component: '/demos/access/index',
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:page-previous-outline',
|
|
||||||
title: 'demos.access.pageAccess',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AccessButtonControlDemo',
|
|
||||||
path: '/demos/access/button-control',
|
|
||||||
component: '/demos/access/button-control',
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:button-cursor',
|
|
||||||
title: 'demos.access.buttonControl',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AccessMenuVisible403Demo',
|
|
||||||
path: '/demos/access/menu-visible-403',
|
|
||||||
component: '/demos/access/menu-visible-403',
|
|
||||||
meta: {
|
|
||||||
authority: ['no-body'],
|
|
||||||
icon: 'mdi:button-cursor',
|
|
||||||
menuVisibleWithForbidden: true,
|
|
||||||
title: 'demos.access.menuVisible403',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
roleWithMenus[role],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MOCK_MENUS = [
|
|
||||||
{
|
|
||||||
menus: [...dashboardMenus, ...analyticsMenus, ...financeMenus, ...createDemosMenus('super')],
|
|
||||||
username: 'vben',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
menus: [...dashboardMenus, ...analyticsMenus, ...financeMenus, ...createDemosMenus('admin')],
|
|
||||||
username: 'admin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
menus: [...dashboardMenus, ...analyticsMenus, ...financeMenus, ...createDemosMenus('user')],
|
|
||||||
username: 'jack',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const MOCK_MENU_LIST = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Workspace',
|
|
||||||
status: 1,
|
|
||||||
type: 'menu',
|
|
||||||
icon: 'mdi:dashboard',
|
|
||||||
path: '/workspace',
|
|
||||||
component: '/dashboard/workspace/index',
|
|
||||||
meta: {
|
|
||||||
icon: 'carbon:workspace',
|
|
||||||
title: 'page.dashboard.workspace',
|
|
||||||
affixTab: true,
|
|
||||||
order: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
meta: {
|
|
||||||
icon: 'carbon:settings',
|
|
||||||
order: 9997,
|
|
||||||
title: 'system.title',
|
|
||||||
badge: 'new',
|
|
||||||
badgeType: 'normal',
|
|
||||||
badgeVariants: 'primary',
|
|
||||||
},
|
|
||||||
status: 1,
|
|
||||||
type: 'catalog',
|
|
||||||
name: 'System',
|
|
||||||
path: '/system',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 201,
|
|
||||||
pid: 2,
|
|
||||||
path: '/system/menu',
|
|
||||||
name: 'SystemMenu',
|
|
||||||
authCode: 'System:Menu:List',
|
|
||||||
status: 1,
|
|
||||||
type: 'menu',
|
|
||||||
meta: {
|
|
||||||
icon: 'carbon:menu',
|
|
||||||
title: 'system.menu.title',
|
|
||||||
},
|
|
||||||
component: '/system/menu/list',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 20_101,
|
|
||||||
pid: 201,
|
|
||||||
name: 'SystemMenuCreate',
|
|
||||||
status: 1,
|
|
||||||
type: 'button',
|
|
||||||
authCode: 'System:Menu:Create',
|
|
||||||
meta: { title: 'common.create' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 20_102,
|
|
||||||
pid: 201,
|
|
||||||
name: 'SystemMenuEdit',
|
|
||||||
status: 1,
|
|
||||||
type: 'button',
|
|
||||||
authCode: 'System:Menu:Edit',
|
|
||||||
meta: { title: 'common.edit' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 20_103,
|
|
||||||
pid: 201,
|
|
||||||
name: 'SystemMenuDelete',
|
|
||||||
status: 1,
|
|
||||||
type: 'button',
|
|
||||||
authCode: 'System:Menu:Delete',
|
|
||||||
meta: { title: 'common.delete' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 202,
|
|
||||||
pid: 2,
|
|
||||||
path: '/system/dept',
|
|
||||||
name: 'SystemDept',
|
|
||||||
status: 1,
|
|
||||||
type: 'menu',
|
|
||||||
authCode: 'System:Dept:List',
|
|
||||||
meta: {
|
|
||||||
icon: 'carbon:container-services',
|
|
||||||
title: 'system.dept.title',
|
|
||||||
},
|
|
||||||
component: '/system/dept/list',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 20_401,
|
|
||||||
pid: 201,
|
|
||||||
name: 'SystemDeptCreate',
|
|
||||||
status: 1,
|
|
||||||
type: 'button',
|
|
||||||
authCode: 'System:Dept:Create',
|
|
||||||
meta: { title: 'common.create' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 20_402,
|
|
||||||
pid: 201,
|
|
||||||
name: 'SystemDeptEdit',
|
|
||||||
status: 1,
|
|
||||||
type: 'button',
|
|
||||||
authCode: 'System:Dept:Edit',
|
|
||||||
meta: { title: 'common.edit' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 20_403,
|
|
||||||
pid: 201,
|
|
||||||
name: 'SystemDeptDelete',
|
|
||||||
status: 1,
|
|
||||||
type: 'button',
|
|
||||||
authCode: 'System:Dept:Delete',
|
|
||||||
meta: { title: 'common.delete' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
meta: {
|
|
||||||
badgeType: 'dot',
|
|
||||||
order: 9998,
|
|
||||||
title: 'demos.vben.title',
|
|
||||||
icon: 'carbon:data-center',
|
|
||||||
},
|
|
||||||
name: 'Project',
|
|
||||||
path: '/vben-admin',
|
|
||||||
type: 'catalog',
|
|
||||||
status: 1,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 901,
|
|
||||||
pid: 9,
|
|
||||||
name: 'VbenDocument',
|
|
||||||
path: '/vben-admin/document',
|
|
||||||
component: 'IFrameView',
|
|
||||||
type: 'embedded',
|
|
||||||
status: 1,
|
|
||||||
meta: {
|
|
||||||
icon: 'carbon:book',
|
|
||||||
iframeSrc: 'https://doc.vben.pro',
|
|
||||||
title: 'demos.vben.document',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 902,
|
|
||||||
pid: 9,
|
|
||||||
name: 'VbenGithub',
|
|
||||||
path: '/vben-admin/github',
|
|
||||||
component: 'IFrameView',
|
|
||||||
type: 'link',
|
|
||||||
status: 1,
|
|
||||||
meta: {
|
|
||||||
icon: 'carbon:logo-github',
|
|
||||||
link: 'https://github.com/vbenjs/vue-vben-admin',
|
|
||||||
title: 'Github',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 903,
|
|
||||||
pid: 9,
|
|
||||||
name: 'VbenAntdv',
|
|
||||||
path: '/vben-admin/antdv',
|
|
||||||
component: 'IFrameView',
|
|
||||||
type: 'link',
|
|
||||||
status: 0,
|
|
||||||
meta: {
|
|
||||||
icon: 'carbon:hexagon-vertical-solid',
|
|
||||||
badgeType: 'dot',
|
|
||||||
link: 'https://ant.vben.pro',
|
|
||||||
title: 'demos.vben.antdv',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
component: '_core/about/index',
|
|
||||||
type: 'menu',
|
|
||||||
status: 1,
|
|
||||||
meta: {
|
|
||||||
icon: 'lucide:copyright',
|
|
||||||
order: 9999,
|
|
||||||
title: 'demos.vben.about',
|
|
||||||
},
|
|
||||||
name: 'About',
|
|
||||||
path: '/about',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getMenuIds(menus: any[]) {
|
|
||||||
const ids: number[] = [];
|
|
||||||
menus.forEach((item) => {
|
|
||||||
ids.push(item.id);
|
|
||||||
if (item.children && item.children.length > 0) {
|
|
||||||
ids.push(...getMenuIds(item.children));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
17
apps/backend/api/finance/accounts.get.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { getQuery } from 'h3';
|
||||||
|
|
||||||
|
import { listAccounts } from '~/utils/finance-metadata';
|
||||||
|
import { useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event);
|
||||||
|
const currency = query.currency as string | undefined;
|
||||||
|
|
||||||
|
let accounts = listAccounts();
|
||||||
|
|
||||||
|
if (currency) {
|
||||||
|
accounts = accounts.filter((account) => account.currency === currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useResponseSuccess(accounts);
|
||||||
|
});
|
||||||
10
apps/backend/api/finance/budgets.get.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineEventHandler } from '#nitro';
|
||||||
|
|
||||||
|
import { MOCK_BUDGETS } from '../../utils/mock-data';
|
||||||
|
import { useResponseSuccess } from '../../utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(() => {
|
||||||
|
// 返回未删除的预算
|
||||||
|
const budgets = MOCK_BUDGETS.filter((b) => !b.isDeleted);
|
||||||
|
return useResponseSuccess(budgets);
|
||||||
|
});
|
||||||
33
apps/backend/api/finance/budgets.post.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineEventHandler, readBody } from '#nitro';
|
||||||
|
|
||||||
|
import { MOCK_BUDGETS } from '../../utils/mock-data';
|
||||||
|
import { useResponseSuccess } from '../../utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
const newBudget = {
|
||||||
|
id: Date.now(),
|
||||||
|
userId: 1,
|
||||||
|
category: body.category,
|
||||||
|
categoryId: body.categoryId,
|
||||||
|
emoji: body.emoji,
|
||||||
|
limit: body.limit,
|
||||||
|
spent: body.spent || 0,
|
||||||
|
remaining: body.remaining || body.limit,
|
||||||
|
percentage: body.percentage || 0,
|
||||||
|
currency: body.currency,
|
||||||
|
period: body.period,
|
||||||
|
alertThreshold: body.alertThreshold,
|
||||||
|
description: body.description,
|
||||||
|
autoRenew: body.autoRenew,
|
||||||
|
overspendAlert: body.overspendAlert,
|
||||||
|
dailyReminder: body.dailyReminder,
|
||||||
|
monthlyTrend: body.monthlyTrend || 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
isDeleted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
MOCK_BUDGETS.push(newBudget);
|
||||||
|
return useResponseSuccess(newBudget);
|
||||||
|
});
|
||||||
22
apps/backend/api/finance/budgets/[id].delete.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineEventHandler, getRouterParam } from '#nitro';
|
||||||
|
|
||||||
|
import { MOCK_BUDGETS } from '../../../utils/mock-data';
|
||||||
|
import { useResponseError, useResponseSuccess } from '../../../utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, 'id'));
|
||||||
|
const index = MOCK_BUDGETS.findIndex((b) => b.id === id);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
return useResponseError('预算不存在', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软删除
|
||||||
|
MOCK_BUDGETS[index] = {
|
||||||
|
...MOCK_BUDGETS[index],
|
||||||
|
isDeleted: true,
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return useResponseSuccess({ message: '删除成功' });
|
||||||
|
});
|
||||||
48
apps/backend/api/finance/budgets/[id].put.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineEventHandler, getRouterParam, readBody } from '#nitro';
|
||||||
|
|
||||||
|
import { MOCK_BUDGETS } from '../../../utils/mock-data';
|
||||||
|
import { useResponseError, useResponseSuccess } from '../../../utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, 'id'));
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
const index = MOCK_BUDGETS.findIndex((b) => b.id === id);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
return useResponseError('预算不存在', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是恢复操作
|
||||||
|
if (body.isDeleted === false) {
|
||||||
|
MOCK_BUDGETS[index] = {
|
||||||
|
...MOCK_BUDGETS[index],
|
||||||
|
isDeleted: false,
|
||||||
|
deletedAt: undefined,
|
||||||
|
};
|
||||||
|
return useResponseSuccess(MOCK_BUDGETS[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通更新
|
||||||
|
const updatedBudget = {
|
||||||
|
...MOCK_BUDGETS[index],
|
||||||
|
category: body.category ?? MOCK_BUDGETS[index].category,
|
||||||
|
categoryId: body.categoryId ?? MOCK_BUDGETS[index].categoryId,
|
||||||
|
emoji: body.emoji ?? MOCK_BUDGETS[index].emoji,
|
||||||
|
limit: body.limit ?? MOCK_BUDGETS[index].limit,
|
||||||
|
spent: body.spent ?? MOCK_BUDGETS[index].spent,
|
||||||
|
remaining: body.remaining ?? MOCK_BUDGETS[index].remaining,
|
||||||
|
percentage: body.percentage ?? MOCK_BUDGETS[index].percentage,
|
||||||
|
currency: body.currency ?? MOCK_BUDGETS[index].currency,
|
||||||
|
period: body.period ?? MOCK_BUDGETS[index].period,
|
||||||
|
alertThreshold: body.alertThreshold ?? MOCK_BUDGETS[index].alertThreshold,
|
||||||
|
description: body.description ?? MOCK_BUDGETS[index].description,
|
||||||
|
autoRenew: body.autoRenew ?? MOCK_BUDGETS[index].autoRenew,
|
||||||
|
overspendAlert: body.overspendAlert ?? MOCK_BUDGETS[index].overspendAlert,
|
||||||
|
dailyReminder: body.dailyReminder ?? MOCK_BUDGETS[index].dailyReminder,
|
||||||
|
monthlyTrend: body.monthlyTrend ?? MOCK_BUDGETS[index].monthlyTrend,
|
||||||
|
};
|
||||||
|
|
||||||
|
MOCK_BUDGETS[index] = updatedBudget;
|
||||||
|
return useResponseSuccess(updatedBudget);
|
||||||
|
});
|
||||||
13
apps/backend/api/finance/categories.get.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { getQuery } from 'h3';
|
||||||
|
|
||||||
|
import { fetchCategories } from '~/utils/finance-repository';
|
||||||
|
import { useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event);
|
||||||
|
const type = query.type as 'income' | 'expense' | undefined;
|
||||||
|
|
||||||
|
const categories = fetchCategories({ type });
|
||||||
|
|
||||||
|
return useResponseSuccess(categories);
|
||||||
|
});
|
||||||
23
apps/backend/api/finance/categories.post.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { readBody } from 'h3';
|
||||||
|
|
||||||
|
import { createCategoryRecord } from '~/utils/finance-metadata';
|
||||||
|
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
if (!body?.name || !body?.type) {
|
||||||
|
return useResponseError('分类名称和类型为必填项', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = createCategoryRecord({
|
||||||
|
name: body.name,
|
||||||
|
type: body.type,
|
||||||
|
icon: body.icon,
|
||||||
|
color: body.color,
|
||||||
|
userId: 1,
|
||||||
|
isActive: body.isActive ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useResponseSuccess(category);
|
||||||
|
});
|
||||||
18
apps/backend/api/finance/categories/[id].delete.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { getRouterParam } from 'h3';
|
||||||
|
|
||||||
|
import { deleteCategoryRecord } from '~/utils/finance-metadata';
|
||||||
|
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, 'id'));
|
||||||
|
if (Number.isNaN(id)) {
|
||||||
|
return useResponseError('参数错误', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = deleteCategoryRecord(id);
|
||||||
|
if (!deleted) {
|
||||||
|
return useResponseError('分类不存在', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useResponseSuccess({ message: '删除成功' });
|
||||||
|
});
|
||||||
27
apps/backend/api/finance/categories/[id].put.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { getRouterParam, readBody } from 'h3';
|
||||||
|
|
||||||
|
import { updateCategoryRecord } from '~/utils/finance-metadata';
|
||||||
|
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, 'id'));
|
||||||
|
if (Number.isNaN(id)) {
|
||||||
|
return useResponseError('参数错误', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
const updated = updateCategoryRecord(id, {
|
||||||
|
name: body?.name,
|
||||||
|
icon: body?.icon,
|
||||||
|
color: body?.color,
|
||||||
|
userId: body?.userId,
|
||||||
|
isActive: body?.isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return useResponseError('分类不存在', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useResponseSuccess(updated);
|
||||||
|
});
|
||||||
6
apps/backend/api/finance/currencies.get.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { listCurrencies } from '~/utils/finance-metadata';
|
||||||
|
import { useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
return useResponseSuccess(listCurrencies());
|
||||||
|
});
|
||||||
30
apps/backend/api/finance/exchange-rates.get.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { getQuery } from 'h3';
|
||||||
|
|
||||||
|
import { listExchangeRates } from '~/utils/finance-metadata';
|
||||||
|
import { useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event);
|
||||||
|
const fromCurrency = query.from as string | undefined;
|
||||||
|
const toCurrency = query.to as string | undefined;
|
||||||
|
const date = query.date as string | undefined;
|
||||||
|
|
||||||
|
let rates = listExchangeRates();
|
||||||
|
|
||||||
|
if (fromCurrency) {
|
||||||
|
rates = rates.filter((rate) => rate.fromCurrency === fromCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toCurrency) {
|
||||||
|
rates = rates.filter((rate) => rate.toCurrency === toCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date) {
|
||||||
|
rates = rates.filter((rate) => rate.date === date);
|
||||||
|
} else if (rates.length > 0) {
|
||||||
|
const latestDate = rates.reduce((max, rate) => (rate.date > max ? rate.date : max), rates[0].date);
|
||||||
|
rates = rates.filter((rate) => rate.date === latestDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useResponseSuccess(rates);
|
||||||
|
});
|
||||||
12
apps/backend/api/finance/transactions.get.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { getQuery } from 'h3';
|
||||||
|
|
||||||
|
import { fetchTransactions } from '~/utils/finance-repository';
|
||||||
|
import { useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event);
|
||||||
|
const type = query.type as string | undefined;
|
||||||
|
const transactions = fetchTransactions({ type });
|
||||||
|
|
||||||
|
return useResponseSuccess(transactions);
|
||||||
|
});
|
||||||
33
apps/backend/api/finance/transactions.post.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { readBody } from 'h3';
|
||||||
|
|
||||||
|
import { createTransaction } from '~/utils/finance-repository';
|
||||||
|
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
const DEFAULT_CURRENCY = 'CNY';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
if (!body?.type || !body?.amount || !body?.transactionDate) {
|
||||||
|
return useResponseError('缺少必填字段', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(body.amount);
|
||||||
|
if (Number.isNaN(amount)) {
|
||||||
|
return useResponseError('金额格式不正确', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = createTransaction({
|
||||||
|
type: body.type,
|
||||||
|
amount,
|
||||||
|
currency: body.currency ?? DEFAULT_CURRENCY,
|
||||||
|
categoryId: body.categoryId ?? null,
|
||||||
|
accountId: body.accountId ?? null,
|
||||||
|
transactionDate: body.transactionDate,
|
||||||
|
description: body.description ?? '',
|
||||||
|
project: body.project ?? null,
|
||||||
|
memo: body.memo ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useResponseSuccess(transaction);
|
||||||
|
});
|
||||||
19
apps/backend/api/finance/transactions/[id].delete.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { getRouterParam } from 'h3';
|
||||||
|
|
||||||
|
import { softDeleteTransaction } from '~/utils/finance-repository';
|
||||||
|
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, 'id'));
|
||||||
|
|
||||||
|
if (Number.isNaN(id)) {
|
||||||
|
return useResponseError('参数错误', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = softDeleteTransaction(id);
|
||||||
|
if (!updated) {
|
||||||
|
return useResponseError('交易不存在', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useResponseSuccess({ message: '删除成功' });
|
||||||
|
});
|
||||||
47
apps/backend/api/finance/transactions/[id].put.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { getRouterParam, readBody } from 'h3';
|
||||||
|
|
||||||
|
import { restoreTransaction, updateTransaction } from '~/utils/finance-repository';
|
||||||
|
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, 'id'));
|
||||||
|
if (Number.isNaN(id)) {
|
||||||
|
return useResponseError('参数错误', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
if (body?.isDeleted === false) {
|
||||||
|
const restored = restoreTransaction(id);
|
||||||
|
if (!restored) {
|
||||||
|
return useResponseError('交易不存在', -1);
|
||||||
|
}
|
||||||
|
return useResponseSuccess(restored);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (body?.type) payload.type = body.type;
|
||||||
|
if (body?.amount !== undefined) {
|
||||||
|
const amount = Number(body.amount);
|
||||||
|
if (Number.isNaN(amount)) {
|
||||||
|
return useResponseError('金额格式不正确', -1);
|
||||||
|
}
|
||||||
|
payload.amount = amount;
|
||||||
|
}
|
||||||
|
if (body?.currency) payload.currency = body.currency;
|
||||||
|
if (body?.categoryId !== undefined) payload.categoryId = body.categoryId ?? null;
|
||||||
|
if (body?.accountId !== undefined) payload.accountId = body.accountId ?? null;
|
||||||
|
if (body?.transactionDate) payload.transactionDate = body.transactionDate;
|
||||||
|
if (body?.description !== undefined) payload.description = body.description ?? '';
|
||||||
|
if (body?.project !== undefined) payload.project = body.project ?? null;
|
||||||
|
if (body?.memo !== undefined) payload.memo = body.memo ?? null;
|
||||||
|
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
|
||||||
|
|
||||||
|
const updated = updateTransaction(id, payload);
|
||||||
|
if (!updated) {
|
||||||
|
return useResponseError('交易不存在', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useResponseSuccess(updated);
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@vben/backend-mock",
|
"name": "@vben/backend",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -7,10 +7,12 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nitro build",
|
"build": "nitro build",
|
||||||
"start": "nitro dev"
|
"start": "nitro dev",
|
||||||
|
"import:data": "node scripts/import-finance-data.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@faker-js/faker": "catalog:",
|
"@faker-js/faker": "catalog:",
|
||||||
|
"better-sqlite3": "9.5.0",
|
||||||
"jsonwebtoken": "catalog:",
|
"jsonwebtoken": "catalog:",
|
||||||
"nitropack": "catalog:"
|
"nitropack": "catalog:"
|
||||||
},
|
},
|
||||||
363
apps/backend/scripts/import-finance-data.js
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const params = {};
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg.startsWith('--')) {
|
||||||
|
const key = arg.slice(2);
|
||||||
|
const next = args[i + 1];
|
||||||
|
if (!next || next.startsWith('--')) {
|
||||||
|
params[key] = true;
|
||||||
|
} else {
|
||||||
|
params[key] = next;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.csv) {
|
||||||
|
console.error('请通过 --csv <路径> 指定 CSV 数据文件');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputPath = path.resolve(params.csv);
|
||||||
|
if (!fs.existsSync(inputPath)) {
|
||||||
|
console.error(`无法找到 CSV 文件: ${inputPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseYear = params.year ? Number(params.year) : 2024;
|
||||||
|
if (Number.isNaN(baseYear)) {
|
||||||
|
console.error('参数 --year 必须为数字');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeDir = path.join(process.cwd(), 'storage');
|
||||||
|
fs.mkdirSync(storeDir, { recursive: true });
|
||||||
|
const dbFile = path.join(storeDir, 'finance.db');
|
||||||
|
const db = new Database(dbFile);
|
||||||
|
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS finance_currencies (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
is_base INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS finance_exchange_rates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
from_currency TEXT NOT NULL,
|
||||||
|
to_currency TEXT NOT NULL,
|
||||||
|
rate REAL NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
source TEXT DEFAULT 'manual'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS finance_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
type TEXT DEFAULT 'cash',
|
||||||
|
balance REAL DEFAULT 0,
|
||||||
|
icon TEXT,
|
||||||
|
color TEXT,
|
||||||
|
user_id INTEGER DEFAULT 1,
|
||||||
|
is_active INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS finance_categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
icon TEXT,
|
||||||
|
color TEXT,
|
||||||
|
user_id INTEGER DEFAULT 1,
|
||||||
|
is_active INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS finance_transactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
exchange_rate_to_base REAL NOT NULL,
|
||||||
|
amount_in_base REAL NOT NULL,
|
||||||
|
category_id INTEGER,
|
||||||
|
account_id INTEGER,
|
||||||
|
transaction_date TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
project TEXT,
|
||||||
|
memo TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_at TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const RAW_TEXT = fs.readFileSync(inputPath, 'utf-8').replace(/^\ufeff/, '');
|
||||||
|
const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||||
|
if (lines.length <= 1) {
|
||||||
|
console.error('CSV 文件内容为空');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = lines[0].split(',');
|
||||||
|
const DATE_IDX = header.indexOf('日期');
|
||||||
|
const PROJECT_IDX = header.indexOf('项目');
|
||||||
|
const TYPE_IDX = header.indexOf('收支');
|
||||||
|
const AMOUNT_IDX = header.indexOf('金额');
|
||||||
|
const ACCOUNT_IDX = header.indexOf('支出人');
|
||||||
|
const CATEGORY_IDX = header.indexOf('计入');
|
||||||
|
const SHARE_IDX = header.indexOf('阿德应得分红');
|
||||||
|
|
||||||
|
if (DATE_IDX === -1 || PROJECT_IDX === -1 || TYPE_IDX === -1 || AMOUNT_IDX === -1 || ACCOUNT_IDX === -1 || CATEGORY_IDX === -1) {
|
||||||
|
console.error('CSV 表头缺少必需字段');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENCIES = [
|
||||||
|
{ code: 'CNY', name: '人民币', symbol: '¥', isBase: true },
|
||||||
|
{ code: 'USD', name: '美元', symbol: '$', isBase: false },
|
||||||
|
{ code: 'THB', name: '泰铢', symbol: '฿', isBase: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXCHANGE_RATES = [
|
||||||
|
{ fromCurrency: 'CNY', toCurrency: 'CNY', rate: 1, date: `${baseYear}-01-01`, source: 'system' },
|
||||||
|
{ fromCurrency: 'USD', toCurrency: 'CNY', rate: 7.14, date: `${baseYear}-01-01`, source: 'manual' },
|
||||||
|
{ fromCurrency: 'THB', toCurrency: 'CNY', rate: 0.2, date: `${baseYear}-01-01`, source: 'manual' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_EXPENSE_CATEGORY = '未分类支出';
|
||||||
|
const DEFAULT_INCOME_CATEGORY = '未分类收入';
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM finance_transactions').run();
|
||||||
|
db.prepare('DELETE FROM finance_accounts').run();
|
||||||
|
db.prepare('DELETE FROM finance_categories').run();
|
||||||
|
db.prepare('DELETE FROM finance_currencies').run();
|
||||||
|
db.prepare('DELETE FROM finance_exchange_rates').run();
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
const insertCurrency = db.prepare(`
|
||||||
|
INSERT INTO finance_currencies (code, name, symbol, is_base, is_active)
|
||||||
|
VALUES (@code, @name, @symbol, @isBase, 1)
|
||||||
|
`);
|
||||||
|
for (const currency of CURRENCIES) {
|
||||||
|
insertCurrency.run({
|
||||||
|
code: currency.code,
|
||||||
|
name: currency.name,
|
||||||
|
symbol: currency.symbol,
|
||||||
|
isBase: currency.isBase ? 1 : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const insertRate = db.prepare(`
|
||||||
|
INSERT INTO finance_exchange_rates (from_currency, to_currency, rate, date, source)
|
||||||
|
VALUES (@fromCurrency, @toCurrency, @rate, @date, @source)
|
||||||
|
`);
|
||||||
|
for (const rate of EXCHANGE_RATES) {
|
||||||
|
insertRate.run(rate);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function inferCurrency(accountName, amountText) {
|
||||||
|
const name = accountName ?? '';
|
||||||
|
const text = `${name}${amountText ?? ''}`;
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
if (lower.includes('美金') || lower.includes('usd') || lower.includes('u$') || lower.includes('u ')) {
|
||||||
|
return 'USD';
|
||||||
|
}
|
||||||
|
if (lower.includes('泰铢') || lower.includes('thb')) {
|
||||||
|
return 'THB';
|
||||||
|
}
|
||||||
|
return 'CNY';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAmount(raw) {
|
||||||
|
if (!raw) return 0;
|
||||||
|
const matches = String(raw)
|
||||||
|
.replace(/[^0-9.+-]/g, (char) => (char === '+' || char === '-' ? char : ' '))
|
||||||
|
.match(/[-+]?\d+(?:\.\d+)?/g);
|
||||||
|
if (!matches) return 0;
|
||||||
|
return matches.map(Number).reduce((sum, value) => sum + value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDate(value, monthTracker) {
|
||||||
|
const cleaned = value.trim();
|
||||||
|
const match = cleaned.match(/(\d{1,2})月(\d{1,2})日/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`无法解析日期: ${value}`);
|
||||||
|
}
|
||||||
|
const month = Number(match[1]);
|
||||||
|
const day = Number(match[2]);
|
||||||
|
let year = baseYear;
|
||||||
|
if (monthTracker.lastMonth !== null && month > monthTracker.lastMonth && monthTracker.wrapped) {
|
||||||
|
year -= 1;
|
||||||
|
}
|
||||||
|
if (monthTracker.lastMonth !== null && month < monthTracker.lastMonth && !monthTracker.wrapped) {
|
||||||
|
monthTracker.wrapped = true;
|
||||||
|
}
|
||||||
|
monthTracker.lastMonth = month;
|
||||||
|
const iso = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountMap = new Map();
|
||||||
|
const categoryMap = new Map();
|
||||||
|
|
||||||
|
const insertAccount = db.prepare(`
|
||||||
|
INSERT INTO finance_accounts (name, currency, type, balance, icon, color, user_id, is_active)
|
||||||
|
VALUES (@name, @currency, @type, 0, @icon, @color, 1, 1)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertCategory = db.prepare(`
|
||||||
|
INSERT INTO finance_categories (name, type, icon, color, user_id, is_active)
|
||||||
|
VALUES (@name, @type, @icon, @color, 1, 1)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
if (!categoryMap.has(`${DEFAULT_INCOME_CATEGORY}-income`)) {
|
||||||
|
const info = insertCategory.run({ name: DEFAULT_INCOME_CATEGORY, type: 'income', icon: '💰', color: '#10b981' });
|
||||||
|
categoryMap.set(`${DEFAULT_INCOME_CATEGORY}-income`, info.lastInsertRowid);
|
||||||
|
}
|
||||||
|
if (!categoryMap.has(`${DEFAULT_EXPENSE_CATEGORY}-expense`)) {
|
||||||
|
const info = insertCategory.run({ name: DEFAULT_EXPENSE_CATEGORY, type: 'expense', icon: '🏷️', color: '#6366f1' });
|
||||||
|
categoryMap.set(`${DEFAULT_EXPENSE_CATEGORY}-expense`, info.lastInsertRowid);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const monthTracker = { lastMonth: null, wrapped: false };
|
||||||
|
let carryDate = '';
|
||||||
|
const transactions = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i += 1) {
|
||||||
|
const row = lines[i].split(',');
|
||||||
|
while (row.length < header.length) row.push('');
|
||||||
|
|
||||||
|
const rawDate = row[DATE_IDX].trim();
|
||||||
|
if (rawDate) {
|
||||||
|
carryDate = normalizeDate(rawDate, monthTracker);
|
||||||
|
}
|
||||||
|
if (!carryDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = row[PROJECT_IDX].trim();
|
||||||
|
const typeText = row[TYPE_IDX].trim();
|
||||||
|
const amountRaw = row[AMOUNT_IDX].trim();
|
||||||
|
const accountNameRaw = row[ACCOUNT_IDX].trim();
|
||||||
|
const categoryRaw = row[CATEGORY_IDX].trim();
|
||||||
|
const shareRaw = SHARE_IDX >= 0 ? row[SHARE_IDX].trim() : '';
|
||||||
|
|
||||||
|
const amount = parseAmount(amountRaw);
|
||||||
|
if (!amount) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedType = typeText.includes('收') && !typeText.includes('支') ? 'income' : 'expense';
|
||||||
|
const accountName = accountNameRaw || '美金现金';
|
||||||
|
const currency = inferCurrency(accountNameRaw, amountRaw);
|
||||||
|
|
||||||
|
if (!accountMap.has(accountName)) {
|
||||||
|
const icon = currency === 'USD' ? '💵' : currency === 'THB' ? '💱' : '💰';
|
||||||
|
const color = currency === 'USD' ? '#1677ff' : currency === 'THB' ? '#22c55e' : '#6366f1';
|
||||||
|
const info = insertAccount.run({
|
||||||
|
name: accountName,
|
||||||
|
currency,
|
||||||
|
type: 'cash',
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
accountMap.set(accountName, Number(info.lastInsertRowid));
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryName = categoryRaw || (normalizedType === 'income' ? DEFAULT_INCOME_CATEGORY : DEFAULT_EXPENSE_CATEGORY);
|
||||||
|
const categoryKey = `${categoryName}-${normalizedType}`;
|
||||||
|
if (!categoryMap.has(categoryKey)) {
|
||||||
|
const icon = normalizedType === 'income' ? '💰' : '🏷️';
|
||||||
|
const color = normalizedType === 'income' ? '#10b981' : '#fb7185';
|
||||||
|
const info = insertCategory.run({
|
||||||
|
name: categoryName,
|
||||||
|
type: normalizedType,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
categoryMap.set(categoryKey, Number(info.lastInsertRowid));
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptionParts = [];
|
||||||
|
if (project) descriptionParts.push(project);
|
||||||
|
if (categoryRaw) descriptionParts.push(`计入: ${categoryRaw}`);
|
||||||
|
if (shareRaw) descriptionParts.push(`分红: ${shareRaw}`);
|
||||||
|
|
||||||
|
const description = descriptionParts.join(' | ');
|
||||||
|
transactions.push({
|
||||||
|
type: normalizedType,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
categoryId: categoryMap.get(categoryKey) ?? null,
|
||||||
|
accountId: accountMap.get(accountName) ?? null,
|
||||||
|
transactionDate: carryDate,
|
||||||
|
description,
|
||||||
|
project: project || null,
|
||||||
|
memo: shareRaw || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertTransaction = db.prepare(`
|
||||||
|
INSERT INTO finance_transactions (
|
||||||
|
type,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
exchange_rate_to_base,
|
||||||
|
amount_in_base,
|
||||||
|
category_id,
|
||||||
|
account_id,
|
||||||
|
transaction_date,
|
||||||
|
description,
|
||||||
|
project,
|
||||||
|
memo,
|
||||||
|
created_at,
|
||||||
|
is_deleted
|
||||||
|
) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getRateStmt = db.prepare(`
|
||||||
|
SELECT rate
|
||||||
|
FROM finance_exchange_rates
|
||||||
|
WHERE from_currency = ? AND to_currency = 'CNY'
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertMany = db.transaction((items) => {
|
||||||
|
for (const item of items) {
|
||||||
|
const rateRow = getRateStmt.get(item.currency);
|
||||||
|
const rate = rateRow ? rateRow.rate : 1;
|
||||||
|
const amountInBase = +(item.amount * rate).toFixed(2);
|
||||||
|
insertTransaction.run({
|
||||||
|
...item,
|
||||||
|
exchangeRateToBase: rate,
|
||||||
|
amountInBase,
|
||||||
|
createdAt: `${item.transactionDate}T00:00:00.000Z`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
insertMany(transactions);
|
||||||
|
|
||||||
|
console.log(`已导入 ${transactions.length} 条交易,账户 ${accountMap.size} 个,分类 ${categoryMap.size} 个。`);
|
||||||
49
apps/backend/utils/finance-metadata.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { MOCK_ACCOUNTS, MOCK_BUDGETS, MOCK_CATEGORIES, MOCK_CURRENCIES, MOCK_EXCHANGE_RATES } from './mock-data';
|
||||||
|
|
||||||
|
export function listAccounts() {
|
||||||
|
return MOCK_ACCOUNTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCategories() {
|
||||||
|
return MOCK_CATEGORIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBudgets() {
|
||||||
|
return MOCK_BUDGETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCurrencies() {
|
||||||
|
return MOCK_CURRENCIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listExchangeRates() {
|
||||||
|
return MOCK_EXCHANGE_RATES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCategoryRecord(category: any) {
|
||||||
|
const newCategory = {
|
||||||
|
...category,
|
||||||
|
id: MOCK_CATEGORIES.length + 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
MOCK_CATEGORIES.push(newCategory);
|
||||||
|
return newCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCategoryRecord(id: number, category: any) {
|
||||||
|
const index = MOCK_CATEGORIES.findIndex(c => c.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
MOCK_CATEGORIES[index] = { ...MOCK_CATEGORIES[index], ...category };
|
||||||
|
return MOCK_CATEGORIES[index];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCategoryRecord(id: number) {
|
||||||
|
const index = MOCK_CATEGORIES.findIndex(c => c.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
MOCK_CATEGORIES.splice(index, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
260
apps/backend/utils/finance-repository.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import db from './sqlite';
|
||||||
|
|
||||||
|
const BASE_CURRENCY = 'CNY';
|
||||||
|
|
||||||
|
interface TransactionRow {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
exchange_rate_to_base: number;
|
||||||
|
amount_in_base: number;
|
||||||
|
category_id: number | null;
|
||||||
|
account_id: number | null;
|
||||||
|
transaction_date: string;
|
||||||
|
description: string | null;
|
||||||
|
project: string | null;
|
||||||
|
memo: string | null;
|
||||||
|
created_at: string;
|
||||||
|
is_deleted: number;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransactionPayload {
|
||||||
|
type: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
categoryId?: number | null;
|
||||||
|
accountId?: number | null;
|
||||||
|
transactionDate: string;
|
||||||
|
description?: string;
|
||||||
|
project?: string | null;
|
||||||
|
memo?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExchangeRateToBase(currency: string) {
|
||||||
|
if (currency === BASE_CURRENCY) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const stmt = db.prepare(
|
||||||
|
`SELECT rate FROM finance_exchange_rates WHERE from_currency = ? AND to_currency = ? ORDER BY date DESC LIMIT 1`,
|
||||||
|
);
|
||||||
|
const row = stmt.get(currency, BASE_CURRENCY) as { rate: number } | undefined;
|
||||||
|
return row?.rate ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTransaction(row: TransactionRow) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: 1,
|
||||||
|
type: row.type as 'income' | 'expense' | 'transfer',
|
||||||
|
amount: row.amount,
|
||||||
|
currency: row.currency,
|
||||||
|
exchangeRateToBase: row.exchange_rate_to_base,
|
||||||
|
amountInBase: row.amount_in_base,
|
||||||
|
categoryId: row.category_id ?? undefined,
|
||||||
|
accountId: row.account_id ?? undefined,
|
||||||
|
transactionDate: row.transaction_date,
|
||||||
|
description: row.description ?? '',
|
||||||
|
project: row.project ?? undefined,
|
||||||
|
memo: row.memo ?? undefined,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
isDeleted: Boolean(row.is_deleted),
|
||||||
|
deletedAt: row.deleted_at ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTransactions(options: { type?: string; includeDeleted?: boolean } = {}) {
|
||||||
|
const clauses: string[] = [];
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (!options.includeDeleted) {
|
||||||
|
clauses.push('is_deleted = 0');
|
||||||
|
}
|
||||||
|
if (options.type) {
|
||||||
|
clauses.push('type = @type');
|
||||||
|
params.type = options.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const stmt = db.prepare<TransactionRow>(
|
||||||
|
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted, deleted_at FROM finance_transactions ${where} ORDER BY transaction_date DESC, id DESC`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return stmt.all(params).map(mapTransaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTransactionById(id: number) {
|
||||||
|
const stmt = db.prepare<TransactionRow>(
|
||||||
|
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted, deleted_at FROM finance_transactions WHERE id = ?`,
|
||||||
|
);
|
||||||
|
const row = stmt.get(id);
|
||||||
|
return row ? mapTransaction(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTransaction(payload: TransactionPayload) {
|
||||||
|
const exchangeRate = getExchangeRateToBase(payload.currency);
|
||||||
|
const amountInBase = +(payload.amount * exchangeRate).toFixed(2);
|
||||||
|
const createdAt = payload.createdAt && payload.createdAt.length ? payload.createdAt : new Date().toISOString();
|
||||||
|
|
||||||
|
const stmt = db.prepare(
|
||||||
|
`INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const info = stmt.run({
|
||||||
|
type: payload.type,
|
||||||
|
amount: payload.amount,
|
||||||
|
currency: payload.currency,
|
||||||
|
exchangeRateToBase: exchangeRate,
|
||||||
|
amountInBase,
|
||||||
|
categoryId: payload.categoryId ?? null,
|
||||||
|
accountId: payload.accountId ?? null,
|
||||||
|
transactionDate: payload.transactionDate,
|
||||||
|
description: payload.description ?? '',
|
||||||
|
project: payload.project ?? null,
|
||||||
|
memo: payload.memo ?? null,
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return getTransactionById(Number(info.lastInsertRowid));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTransaction(id: number, payload: TransactionPayload) {
|
||||||
|
const current = getTransactionById(id);
|
||||||
|
if (!current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
type: payload.type ?? current.type,
|
||||||
|
amount: payload.amount ?? current.amount,
|
||||||
|
currency: payload.currency ?? current.currency,
|
||||||
|
categoryId: payload.categoryId ?? current.categoryId ?? null,
|
||||||
|
accountId: payload.accountId ?? current.accountId ?? null,
|
||||||
|
transactionDate: payload.transactionDate ?? current.transactionDate,
|
||||||
|
description: payload.description ?? current.description ?? '',
|
||||||
|
project: payload.project ?? current.project ?? null,
|
||||||
|
memo: payload.memo ?? current.memo ?? null,
|
||||||
|
isDeleted: payload.isDeleted ?? current.isDeleted,
|
||||||
|
};
|
||||||
|
|
||||||
|
const exchangeRate = getExchangeRateToBase(next.currency);
|
||||||
|
const amountInBase = +(next.amount * exchangeRate).toFixed(2);
|
||||||
|
|
||||||
|
const stmt = db.prepare(
|
||||||
|
`UPDATE finance_transactions SET type = @type, amount = @amount, currency = @currency, exchange_rate_to_base = @exchangeRateToBase, amount_in_base = @amountInBase, category_id = @categoryId, account_id = @accountId, transaction_date = @transactionDate, description = @description, project = @project, memo = @memo, is_deleted = @isDeleted, deleted_at = @deletedAt WHERE id = @id`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deletedAt = next.isDeleted ? new Date().toISOString() : null;
|
||||||
|
|
||||||
|
stmt.run({
|
||||||
|
id,
|
||||||
|
type: next.type,
|
||||||
|
amount: next.amount,
|
||||||
|
currency: next.currency,
|
||||||
|
exchangeRateToBase: exchangeRate,
|
||||||
|
amountInBase,
|
||||||
|
categoryId: next.categoryId,
|
||||||
|
accountId: next.accountId,
|
||||||
|
transactionDate: next.transactionDate,
|
||||||
|
description: next.description,
|
||||||
|
project: next.project,
|
||||||
|
memo: next.memo,
|
||||||
|
isDeleted: next.isDeleted ? 1 : 0,
|
||||||
|
deletedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return getTransactionById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function softDeleteTransaction(id: number) {
|
||||||
|
const stmt = db.prepare(`UPDATE finance_transactions SET is_deleted = 1, deleted_at = @deletedAt WHERE id = @id`);
|
||||||
|
stmt.run({ id, deletedAt: new Date().toISOString() });
|
||||||
|
return getTransactionById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreTransaction(id: number) {
|
||||||
|
const stmt = db.prepare(`UPDATE finance_transactions SET is_deleted = 0, deleted_at = NULL WHERE id = @id`);
|
||||||
|
stmt.run({ id });
|
||||||
|
return getTransactionById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceAllTransactions(rows: Array<{
|
||||||
|
type: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
categoryId: number | null;
|
||||||
|
accountId: number | null;
|
||||||
|
transactionDate: string;
|
||||||
|
description: string;
|
||||||
|
project?: string | null;
|
||||||
|
memo?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
}>) {
|
||||||
|
db.prepare('DELETE FROM finance_transactions').run();
|
||||||
|
|
||||||
|
const insert = db.prepare(
|
||||||
|
`INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRate = db.prepare(
|
||||||
|
`SELECT rate FROM finance_exchange_rates WHERE from_currency = ? AND to_currency = 'CNY' ORDER BY date DESC LIMIT 1`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertMany = db.transaction((items: Array<any>) => {
|
||||||
|
for (const item of items) {
|
||||||
|
const row = getRate.get(item.currency) as { rate: number } | undefined;
|
||||||
|
const rate = row?.rate ?? 1;
|
||||||
|
const amountInBase = +(item.amount * rate).toFixed(2);
|
||||||
|
insert.run({
|
||||||
|
...item,
|
||||||
|
exchangeRateToBase: rate,
|
||||||
|
amountInBase,
|
||||||
|
project: item.project ?? null,
|
||||||
|
memo: item.memo ?? null,
|
||||||
|
createdAt: item.createdAt ?? new Date(`${item.transactionDate}T00:00:00Z`).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
insertMany(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类相关函数
|
||||||
|
interface CategoryRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
icon: string | null;
|
||||||
|
color: string | null;
|
||||||
|
user_id: number | null;
|
||||||
|
is_active: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCategory(row: CategoryRow) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id ?? null,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type as 'income' | 'expense',
|
||||||
|
icon: row.icon ?? '📝',
|
||||||
|
color: row.color ?? '#dfe4ea',
|
||||||
|
sortOrder: row.id,
|
||||||
|
isSystem: row.user_id === null,
|
||||||
|
isActive: Boolean(row.is_active),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCategories(options: { type?: 'income' | 'expense' } = {}) {
|
||||||
|
const where = options.type ? `WHERE type = @type AND is_active = 1` : 'WHERE is_active = 1';
|
||||||
|
const params = options.type ? { type: options.type } : {};
|
||||||
|
|
||||||
|
const stmt = db.prepare<CategoryRow>(
|
||||||
|
`SELECT id, name, type, icon, color, user_id, is_active FROM finance_categories ${where} ORDER BY id ASC`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return stmt.all(params).map(mapCategory);
|
||||||
|
}
|
||||||
1105
apps/backend/utils/mock-data.ts
Normal file
82
apps/backend/utils/sqlite.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'pathe';
|
||||||
|
|
||||||
|
const dbFile = join(process.cwd(), 'storage', 'finance.db');
|
||||||
|
|
||||||
|
mkdirSync(dirname(dbFile), { recursive: true });
|
||||||
|
|
||||||
|
const database = new Database(dbFile);
|
||||||
|
|
||||||
|
database.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS finance_currencies (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
is_base INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS finance_exchange_rates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
from_currency TEXT NOT NULL,
|
||||||
|
to_currency TEXT NOT NULL,
|
||||||
|
rate REAL NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
source TEXT DEFAULT 'manual'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS finance_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
type TEXT DEFAULT 'cash',
|
||||||
|
icon TEXT,
|
||||||
|
color TEXT,
|
||||||
|
user_id INTEGER DEFAULT 1,
|
||||||
|
is_active INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS finance_categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
icon TEXT,
|
||||||
|
color TEXT,
|
||||||
|
user_id INTEGER DEFAULT 1,
|
||||||
|
is_active INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS finance_transactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
exchange_rate_to_base REAL NOT NULL,
|
||||||
|
amount_in_base REAL NOT NULL,
|
||||||
|
category_id INTEGER,
|
||||||
|
account_id INTEGER,
|
||||||
|
transaction_date TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
project TEXT,
|
||||||
|
memo TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_at TEXT,
|
||||||
|
FOREIGN KEY (currency) REFERENCES finance_currencies(code),
|
||||||
|
FOREIGN KEY (category_id) REFERENCES finance_categories(id),
|
||||||
|
FOREIGN KEY (account_id) REFERENCES finance_accounts(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
export default database;
|
||||||
@@ -30,6 +30,96 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<script>
|
||||||
|
// Flatten FinWise Pro menu - Remove parent menu and show children directly
|
||||||
|
(function() {
|
||||||
|
console.log('[FinWise] Script loaded');
|
||||||
|
|
||||||
|
function flattenFinWiseProMenu() {
|
||||||
|
console.log('[FinWise] flattenFinWiseProMenu called');
|
||||||
|
const submenus = document.querySelectorAll('.vben-sub-menu');
|
||||||
|
console.log('[FinWise] Found submenus:', submenus.length);
|
||||||
|
let finwiseMenu = null;
|
||||||
|
|
||||||
|
submenus.forEach(menu => {
|
||||||
|
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
|
||||||
|
if (titleEl && titleEl.textContent) {
|
||||||
|
console.log('[FinWise] Menu title:', titleEl.textContent.trim());
|
||||||
|
if (titleEl.textContent.includes('FinWise Pro')) {
|
||||||
|
finwiseMenu = menu;
|
||||||
|
console.log('[FinWise] Found FinWise Pro menu!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finwiseMenu) {
|
||||||
|
console.log('[FinWise] FinWise Pro menu not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentMenu = finwiseMenu.parentElement;
|
||||||
|
const childrenUL = finwiseMenu.querySelector('.vben-menu');
|
||||||
|
|
||||||
|
if (!childrenUL || !parentMenu) {
|
||||||
|
console.log('[FinWise] No children or parent');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already processed
|
||||||
|
if (finwiseMenu.style.display === 'none') {
|
||||||
|
console.log('[FinWise] Already processed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move all children to the parent menu
|
||||||
|
const children = Array.from(childrenUL.children);
|
||||||
|
console.log('[FinWise] Moving', children.length, 'children');
|
||||||
|
children.forEach(child => {
|
||||||
|
parentMenu.insertBefore(child, finwiseMenu);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide the FinWise Pro submenu
|
||||||
|
finwiseMenu.style.display = 'none';
|
||||||
|
console.log('[FinWise] Successfully flattened menu!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run after DOM loads
|
||||||
|
const delays = [500, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7000, 8000];
|
||||||
|
console.log('[FinWise] Setting up delays:', delays);
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
console.log('[FinWise] Waiting for DOMContentLoaded');
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
console.log('[FinWise] DOMContentLoaded fired');
|
||||||
|
delays.forEach(delay => {
|
||||||
|
setTimeout(flattenFinWiseProMenu, delay);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('[FinWise] DOM already loaded');
|
||||||
|
delays.forEach(delay => {
|
||||||
|
setTimeout(flattenFinWiseProMenu, delay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for DOM changes
|
||||||
|
setTimeout(function() {
|
||||||
|
console.log('[FinWise] Setting up MutationObserver');
|
||||||
|
const observer = new MutationObserver(function() {
|
||||||
|
setTimeout(flattenFinWiseProMenu, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = document.body;
|
||||||
|
if (body) {
|
||||||
|
observer.observe(body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
console.log('[FinWise] MutationObserver active');
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -43,8 +43,11 @@
|
|||||||
"@vueuse/core": "catalog:",
|
"@vueuse/core": "catalog:",
|
||||||
"ant-design-vue": "catalog:",
|
"ant-design-vue": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
|
"echarts": "catalog:",
|
||||||
"pinia": "catalog:",
|
"pinia": "catalog:",
|
||||||
"vue": "catalog:",
|
"vue": "catalog:",
|
||||||
"vue-router": "catalog:"
|
"vue-echarts": "^8.0.0",
|
||||||
|
"vue-router": "catalog:",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
95
apps/web-antd/scripts/add-category-to-csv.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import * as fs from 'node:fs';
|
||||||
|
|
||||||
|
const INPUT_CSV = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正.csv';
|
||||||
|
const OUTPUT_CSV = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
|
||||||
|
|
||||||
|
// 智能分类函数
|
||||||
|
function getCategory(project: string): string {
|
||||||
|
const desc = project.toLowerCase();
|
||||||
|
|
||||||
|
// 工资
|
||||||
|
if (desc.includes('工资') || desc.match(/amy|天天|碧桂园|皇|香缇卡|财务|客服|小哥|代理ip|sy|超鹏|小白/)) {
|
||||||
|
return '工资';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 佣金/返佣
|
||||||
|
if (desc.includes('佣金') || desc.includes('返佣')) {
|
||||||
|
return '佣金/返佣';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分红
|
||||||
|
if (desc.includes('分红') || desc.includes('散户')) {
|
||||||
|
return '分红';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务器/技术
|
||||||
|
if (desc.match(/服务器|技术|chatgpt|openai|ai|接口|ip|nat|宝塔|cdn|oss|google|翻译|openrouter|deepseek|claude|cursor|bolt|硅基|chatwoot/)) {
|
||||||
|
return '服务器/技术';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 广告推广
|
||||||
|
if (desc.match(/广告|推广|地推|投放|打流量/)) {
|
||||||
|
return '广告推广';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软件/工具
|
||||||
|
if (desc.match(/会员|007|u盘|processon|飞机|虚拟卡|小红卡|信用卡|cloudflare|uizard|esim/)) {
|
||||||
|
return '软件/工具';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 固定资产
|
||||||
|
if (desc.match(/买车|电脑|笔记本|显示器|rog|硬盘|服务器.*购买|iphone|路由器|展示屏/)) {
|
||||||
|
return '固定资产';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退款
|
||||||
|
if (desc.includes('退款') || desc.includes('退费') || desc.includes('退')) {
|
||||||
|
return '退款';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 借款/转账
|
||||||
|
if (desc.match(/借|转给|龙腾|投资款|换.*铢|换美金|换现金|报销|房租|生活费|办公室|出差|接待|保关|测试|开工红包/)) {
|
||||||
|
return '借款/转账';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他支出
|
||||||
|
return '其他支出';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取并处理CSV
|
||||||
|
const content = fs.readFileSync(INPUT_CSV, 'utf-8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
// 修改表头,添加"分类"列
|
||||||
|
const header = lines[0];
|
||||||
|
const newHeader = header.trimEnd() + ',分类\n';
|
||||||
|
|
||||||
|
// 处理每一行数据
|
||||||
|
const newLines = [newHeader];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line.trim()) {
|
||||||
|
newLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = line.split(',');
|
||||||
|
if (columns.length < 2) {
|
||||||
|
newLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = columns[1]?.trim() || '';
|
||||||
|
const category = getCategory(project);
|
||||||
|
|
||||||
|
// 添加分类列
|
||||||
|
const newLine = line.trimEnd() + ',' + category + '\n';
|
||||||
|
newLines.push(newLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入新文件
|
||||||
|
fs.writeFileSync(OUTPUT_CSV, newLines.join(''));
|
||||||
|
|
||||||
|
console.log(`✓ 已生成带分类的CSV文件: ${OUTPUT_CSV}`);
|
||||||
|
console.log(`共处理 ${lines.length - 1} 条记录`);
|
||||||
224
apps/web-antd/scripts/import-csv.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
const CSV_FILE = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
|
||||||
|
const API_URL = 'http://localhost:3000/api/finance/transactions';
|
||||||
|
|
||||||
|
interface CSVRow {
|
||||||
|
date: string;
|
||||||
|
project: string;
|
||||||
|
type: string;
|
||||||
|
amount: string;
|
||||||
|
payer: string;
|
||||||
|
account: string;
|
||||||
|
adeShare: string;
|
||||||
|
memo: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析CSV文件
|
||||||
|
function parseCSV(content: string): CSVRow[] {
|
||||||
|
const lines = content.split('\n').slice(1); // 跳过表头
|
||||||
|
const rows: CSVRow[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
const columns = line.split(',');
|
||||||
|
if (columns.length < 6) continue;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
date: columns[0]?.trim() || '',
|
||||||
|
project: columns[1]?.trim() || '',
|
||||||
|
type: columns[2]?.trim() || '',
|
||||||
|
amount: columns[3]?.trim() || '',
|
||||||
|
payer: columns[4]?.trim() || '',
|
||||||
|
account: columns[5]?.trim() || '',
|
||||||
|
adeShare: columns[6]?.trim() || '',
|
||||||
|
memo: columns[7]?.trim() || '',
|
||||||
|
category: columns[9]?.trim() || '', // 分类在第10列(索引9)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换日期格式 - 根据CSV顺序判断年份
|
||||||
|
// CSV顺序: 2024年8-12月 -> 2025年2-7月 -> 2025年8-10月
|
||||||
|
function parseDate(dateStr: string, previousDate: string = ''): string {
|
||||||
|
// 提取月日
|
||||||
|
const match = dateStr.match(/(\d+)月(\d+)日?/);
|
||||||
|
if (match) {
|
||||||
|
const month = Number.parseInt(match[1]);
|
||||||
|
const day = match[2].padStart(2, '0');
|
||||||
|
|
||||||
|
// 根据上一个日期和当前月份判断年份
|
||||||
|
let year = 2024;
|
||||||
|
if (previousDate) {
|
||||||
|
const prevYear = Number.parseInt(previousDate.split('-')[0]);
|
||||||
|
const prevMonth = Number.parseInt(previousDate.split('-')[1]);
|
||||||
|
|
||||||
|
// 如果月份从大变小(例如12月->2月,或7月->8月),说明跨年了
|
||||||
|
if (month < prevMonth) {
|
||||||
|
year = prevYear + 1;
|
||||||
|
} else {
|
||||||
|
year = prevYear;
|
||||||
|
}
|
||||||
|
} else if (month >= 8) {
|
||||||
|
// 第一条记录,8-12月是2024年
|
||||||
|
year = 2024;
|
||||||
|
} else {
|
||||||
|
// 第一条记录,1-7月是2025年
|
||||||
|
year = 2025;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只有月份
|
||||||
|
const monthMatch = dateStr.match(/(\d+)月/);
|
||||||
|
if (monthMatch) {
|
||||||
|
const month = Number.parseInt(monthMatch[1]);
|
||||||
|
let year = 2024;
|
||||||
|
|
||||||
|
if (previousDate) {
|
||||||
|
const prevYear = Number.parseInt(previousDate.split('-')[0]);
|
||||||
|
const prevMonth = Number.parseInt(previousDate.split('-')[1]);
|
||||||
|
|
||||||
|
if (month < prevMonth) {
|
||||||
|
year = prevYear + 1;
|
||||||
|
} else {
|
||||||
|
year = prevYear;
|
||||||
|
}
|
||||||
|
} else if (month >= 8) {
|
||||||
|
year = 2024;
|
||||||
|
} else {
|
||||||
|
year = 2025;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用上一条的日期
|
||||||
|
return previousDate || '2024-08-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析金额,支持加法和乘法表达式
|
||||||
|
function parseAmount(amountStr: string): number {
|
||||||
|
// 移除空格
|
||||||
|
const cleaned = amountStr.trim();
|
||||||
|
|
||||||
|
// 如果包含乘号(*或×或x),先处理乘法
|
||||||
|
if (cleaned.match(/[*×x]/)) {
|
||||||
|
// 提取乘法表达式,如 "200*3=600" 或 "200*3"
|
||||||
|
const mulMatch = cleaned.match(/(\d+(?:\.\d+)?)\s*[*×x]\s*(\d+(?:\.\d+)?)/);
|
||||||
|
if (mulMatch) {
|
||||||
|
const num1 = parseFloat(mulMatch[1]);
|
||||||
|
const num2 = parseFloat(mulMatch[2]);
|
||||||
|
if (!isNaN(num1) && !isNaN(num2)) {
|
||||||
|
return num1 * num2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果包含加号,计算总和
|
||||||
|
if (cleaned.includes('+')) {
|
||||||
|
const parts = cleaned.split('+');
|
||||||
|
let sum = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
const num = parseFloat(part.replace(/[^\d.]/g, ''));
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
sum += num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则直接解析
|
||||||
|
return parseFloat(cleaned.replace(/[^\d.]/g, '')) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据分类名称获取分类ID
|
||||||
|
function getCategoryIdByName(categoryName: string): number {
|
||||||
|
const categoryMap: Record<string, number> = {
|
||||||
|
'工资': 5,
|
||||||
|
'佣金/返佣': 6,
|
||||||
|
'分红': 7,
|
||||||
|
'服务器/技术': 8,
|
||||||
|
'广告推广': 9,
|
||||||
|
'软件/工具': 10,
|
||||||
|
'固定资产': 11,
|
||||||
|
'退款': 12,
|
||||||
|
'借款/转账': 13,
|
||||||
|
'其他支出': 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
return categoryMap[categoryName] || 2; // 默认未分类支出
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量导入
|
||||||
|
async function importTransactions() {
|
||||||
|
const content = fs.readFileSync(CSV_FILE, 'utf-8');
|
||||||
|
const rows = parseCSV(content);
|
||||||
|
|
||||||
|
console.log(`共解析到 ${rows.length} 条记录`);
|
||||||
|
|
||||||
|
let previousDate = '';
|
||||||
|
let imported = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const transactionDate = parseDate(row.date, previousDate);
|
||||||
|
if (transactionDate) {
|
||||||
|
previousDate = transactionDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = parseAmount(row.amount);
|
||||||
|
if (amount <= 0) {
|
||||||
|
console.log(`跳过无效金额的记录: ${row.project} (金额: ${row.amount})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = {
|
||||||
|
type: 'expense', // CSV中都是支出
|
||||||
|
amount,
|
||||||
|
currency: 'USD', // 美金现金
|
||||||
|
transactionDate,
|
||||||
|
description: row.project || '无描述',
|
||||||
|
project: row.project,
|
||||||
|
memo: `支出人: ${row.payer || '未知'} | 账户: ${row.account || '未知'} | 备注: ${row.memo || '无'}`,
|
||||||
|
accountId: 1, // 默认使用美金现金账户 (id=1)
|
||||||
|
categoryId: getCategoryIdByName(row.category), // 使用CSV中的分类
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(transaction),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
imported++;
|
||||||
|
console.log(`✓ 导入成功 [${imported}/${rows.length}]: ${row.project} - $${amount}`);
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
console.error(`✗ 导入失败: ${row.project}`, await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 避免请求过快
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
console.error(`✗ 处理失败: ${row.project}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n导入完成!`);
|
||||||
|
console.log(`成功: ${imported} 条`);
|
||||||
|
console.log(`失败: ${failed} 条`);
|
||||||
|
}
|
||||||
|
|
||||||
|
importTransactions().catch(console.error);
|
||||||
281
apps/web-antd/src/api/core/finance.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { requestClient } from '../request';
|
||||||
|
|
||||||
|
export namespace FinanceApi {
|
||||||
|
// 货币类型
|
||||||
|
export interface Currency {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
isBase: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
userId?: number | null;
|
||||||
|
name: string;
|
||||||
|
type: 'income' | 'expense';
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
isSystem?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账户
|
||||||
|
export interface Account {
|
||||||
|
id: number;
|
||||||
|
userId?: number;
|
||||||
|
name: string;
|
||||||
|
type: 'cash' | 'bank' | 'alipay' | 'wechat' | 'virtual_wallet' | 'investment' | 'credit_card';
|
||||||
|
currency: string;
|
||||||
|
balance?: number;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 汇率
|
||||||
|
export interface ExchangeRate {
|
||||||
|
id: number;
|
||||||
|
fromCurrency: string;
|
||||||
|
toCurrency: string;
|
||||||
|
rate: number;
|
||||||
|
date: string;
|
||||||
|
source: 'manual' | 'api' | 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交易
|
||||||
|
export interface Transaction {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
type: 'income' | 'expense' | 'transfer';
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
exchangeRateToBase: number;
|
||||||
|
amountInBase: number;
|
||||||
|
categoryId?: number | null;
|
||||||
|
accountId?: number | null;
|
||||||
|
transactionDate: string;
|
||||||
|
description: string;
|
||||||
|
project?: string;
|
||||||
|
memo?: string;
|
||||||
|
createdAt: string;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
deletedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建交易的参数
|
||||||
|
export interface CreateTransactionParams {
|
||||||
|
type: 'income' | 'expense' | 'transfer';
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
categoryId?: number;
|
||||||
|
accountId?: number;
|
||||||
|
transactionDate: string;
|
||||||
|
description?: string;
|
||||||
|
project?: string;
|
||||||
|
memo?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预算
|
||||||
|
export interface Budget {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
category: string;
|
||||||
|
categoryId?: number;
|
||||||
|
emoji: string;
|
||||||
|
limit: number;
|
||||||
|
spent: number;
|
||||||
|
remaining: number;
|
||||||
|
percentage: number;
|
||||||
|
currency: string;
|
||||||
|
period: 'monthly' | 'weekly' | 'quarterly' | 'yearly';
|
||||||
|
alertThreshold: number;
|
||||||
|
description?: string;
|
||||||
|
autoRenew: boolean;
|
||||||
|
overspendAlert: boolean;
|
||||||
|
dailyReminder: boolean;
|
||||||
|
monthlyTrend?: number;
|
||||||
|
createdAt: string;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
deletedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建预算的参数
|
||||||
|
export interface CreateBudgetParams {
|
||||||
|
category: string;
|
||||||
|
categoryId?: number;
|
||||||
|
emoji: string;
|
||||||
|
limit: number;
|
||||||
|
spent?: number;
|
||||||
|
remaining?: number;
|
||||||
|
percentage?: number;
|
||||||
|
currency: string;
|
||||||
|
period: 'monthly' | 'weekly' | 'quarterly' | 'yearly';
|
||||||
|
alertThreshold: number;
|
||||||
|
description?: string;
|
||||||
|
autoRenew: boolean;
|
||||||
|
overspendAlert: boolean;
|
||||||
|
dailyReminder: boolean;
|
||||||
|
monthlyTrend?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有货币
|
||||||
|
*/
|
||||||
|
export async function getCurrencies() {
|
||||||
|
return requestClient.get<Currency[]>('/finance/currencies');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分类
|
||||||
|
*/
|
||||||
|
export async function getCategories(params?: { type?: 'income' | 'expense' | 'transfer' }) {
|
||||||
|
return requestClient.get<Category[]>('/finance/categories', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分类
|
||||||
|
*/
|
||||||
|
export async function createCategory(data: {
|
||||||
|
name: string;
|
||||||
|
type: 'income' | 'expense';
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<Category | null>('/finance/categories', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新分类
|
||||||
|
*/
|
||||||
|
export async function updateCategory(
|
||||||
|
id: number,
|
||||||
|
data: {
|
||||||
|
name?: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return requestClient.put<Category | null>(`/finance/categories/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除分类
|
||||||
|
*/
|
||||||
|
export async function deleteCategory(id: number) {
|
||||||
|
return requestClient.delete<{ message: string }>(
|
||||||
|
`/finance/categories/${id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户
|
||||||
|
*/
|
||||||
|
export async function getAccounts(params?: { currency?: string }) {
|
||||||
|
return requestClient.get<Account[]>('/finance/accounts', { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取汇率
|
||||||
|
*/
|
||||||
|
export async function getExchangeRates(params?: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
date?: string;
|
||||||
|
}) {
|
||||||
|
return requestClient.get<ExchangeRate[]>('/finance/exchange-rates', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取交易列表
|
||||||
|
*/
|
||||||
|
export async function getTransactions(params?: {
|
||||||
|
type?: 'income' | 'expense' | 'transfer';
|
||||||
|
}) {
|
||||||
|
return requestClient.get<Transaction[]>('/finance/transactions', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建交易
|
||||||
|
*/
|
||||||
|
export async function createTransaction(data: CreateTransactionParams) {
|
||||||
|
return requestClient.post<Transaction>('/finance/transactions', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新交易
|
||||||
|
*/
|
||||||
|
export async function updateTransaction(
|
||||||
|
id: number,
|
||||||
|
data: Partial<CreateTransactionParams>,
|
||||||
|
) {
|
||||||
|
return requestClient.put<Transaction>(`/finance/transactions/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 软删除交易
|
||||||
|
*/
|
||||||
|
export async function deleteTransaction(id: number) {
|
||||||
|
return requestClient.delete<{ message: string }>(
|
||||||
|
`/finance/transactions/${id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复交易
|
||||||
|
*/
|
||||||
|
export async function restoreTransaction(id: number) {
|
||||||
|
return requestClient.put<Transaction>(`/finance/transactions/${id}`, {
|
||||||
|
isDeleted: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预算列表
|
||||||
|
*/
|
||||||
|
export async function getBudgets() {
|
||||||
|
return requestClient.get<Budget[]>('/finance/budgets');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建预算
|
||||||
|
*/
|
||||||
|
export async function createBudget(data: CreateBudgetParams) {
|
||||||
|
return requestClient.post<Budget>('/finance/budgets', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预算
|
||||||
|
*/
|
||||||
|
export async function updateBudget(
|
||||||
|
id: number,
|
||||||
|
data: Partial<CreateBudgetParams>,
|
||||||
|
) {
|
||||||
|
return requestClient.put<Budget>(`/finance/budgets/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除预算
|
||||||
|
*/
|
||||||
|
export async function deleteBudget(id: number) {
|
||||||
|
return requestClient.delete<{ message: string }>(`/finance/budgets/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复预算
|
||||||
|
*/
|
||||||
|
export async function restoreBudget(id: number) {
|
||||||
|
return requestClient.put<Budget>(`/finance/budgets/${id}`, {
|
||||||
|
isDeleted: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,7 +95,6 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
|||||||
client.addResponseInterceptor(
|
client.addResponseInterceptor(
|
||||||
errorMessageResponseInterceptor((msg: string, error) => {
|
errorMessageResponseInterceptor((msg: string, error) => {
|
||||||
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
|
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
|
||||||
// 当前mock接口返回的错误字段是 error 或者 message
|
|
||||||
const responseData = error?.response?.data ?? {};
|
const responseData = error?.response?.data ?? {};
|
||||||
const errorMessage = responseData?.error ?? responseData?.message ?? '';
|
const errorMessage = responseData?.error ?? responseData?.message ?? '';
|
||||||
// 如果没有错误信息,则会根据状态码进行提示
|
// 如果没有错误信息,则会根据状态码进行提示
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed, onMounted, watch } from 'vue';
|
||||||
|
|
||||||
import { useAntdDesignTokens } from '@vben/hooks';
|
import { useAntdDesignTokens } from '@vben/hooks';
|
||||||
import { preferences, usePreferences } from '@vben/preferences';
|
import { preferences, usePreferences } from '@vben/preferences';
|
||||||
@@ -28,6 +28,119 @@ const tokenTheme = computed(() => {
|
|||||||
token: tokens,
|
token: tokens,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Function to flatten FinWise Pro menu
|
||||||
|
const flattenFinWiseProMenu = () => {
|
||||||
|
const submenus = document.querySelectorAll('.vben-sub-menu');
|
||||||
|
let finwiseMenu: Element | null = null;
|
||||||
|
|
||||||
|
submenus.forEach(menu => {
|
||||||
|
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
|
||||||
|
if (titleEl?.textContent?.includes('FinWise Pro')) {
|
||||||
|
finwiseMenu = menu;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finwiseMenu) return;
|
||||||
|
|
||||||
|
const parentMenu = finwiseMenu.parentElement;
|
||||||
|
const childrenUL = finwiseMenu.querySelector('.vben-menu');
|
||||||
|
|
||||||
|
if (!childrenUL || !parentMenu) return;
|
||||||
|
|
||||||
|
// Check if already processed
|
||||||
|
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
|
||||||
|
|
||||||
|
// Move all children to the parent menu
|
||||||
|
const children = Array.from(childrenUL.children);
|
||||||
|
children.forEach(child => {
|
||||||
|
parentMenu.insertBefore(child, finwiseMenu);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark for hiding via CSS and hide directly
|
||||||
|
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
|
||||||
|
(finwiseMenu as HTMLElement).style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run on mount and watch for route changes
|
||||||
|
onMounted(() => {
|
||||||
|
// 强制修复sidebar设置,防止被用户UI操作覆盖
|
||||||
|
const fixSidebarPreferences = () => {
|
||||||
|
const prefsKey = Object.keys(localStorage).find(k => k.includes('preferences') && !k.includes('locale') && !k.includes('theme'));
|
||||||
|
if (prefsKey) {
|
||||||
|
try {
|
||||||
|
const prefs = JSON.parse(localStorage.getItem(prefsKey) || '{}');
|
||||||
|
if (prefs.value?.sidebar) {
|
||||||
|
// 强制设置侧边栏为展开状态
|
||||||
|
prefs.value.sidebar.collapsed = false;
|
||||||
|
prefs.value.sidebar.expandOnHover = false;
|
||||||
|
prefs.value.sidebar.collapsedButton = false;
|
||||||
|
prefs.value.sidebar.collapsedWidth = 230;
|
||||||
|
localStorage.setItem(prefsKey, JSON.stringify(prefs));
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to fix sidebar preferences:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 立即执行一次
|
||||||
|
fixSidebarPreferences();
|
||||||
|
|
||||||
|
// Run multiple times with increasing delays to catch menu rendering
|
||||||
|
const delays = [100, 300, 500, 1000, 1500, 2000, 2500, 3000, 4000, 5000];
|
||||||
|
delays.forEach(delay => {
|
||||||
|
setTimeout(flattenFinWiseProMenu, delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for DOM changes (when menu is re-rendered)
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setTimeout(flattenFinWiseProMenu, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe the body for menu changes
|
||||||
|
setTimeout(() => {
|
||||||
|
const body = document.body;
|
||||||
|
if (body) {
|
||||||
|
observer.observe(body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// 防止侧边栏自动收起
|
||||||
|
setTimeout(() => {
|
||||||
|
const preventSidebarCollapse = () => {
|
||||||
|
const sidebar = document.querySelector('[class*="sidebar"]') || document.querySelector('aside');
|
||||||
|
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
// 创建MutationObserver监听class和style变化
|
||||||
|
const sidebarObserver = new MutationObserver(() => {
|
||||||
|
const currentWidth = window.getComputedStyle(sidebar).width;
|
||||||
|
// 如果宽度小于200px,说明可能被收起了,强制恢复
|
||||||
|
if (parseInt(currentWidth) < 200) {
|
||||||
|
(sidebar as HTMLElement).style.width = '230px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开始观察
|
||||||
|
sidebarObserver.observe(sidebar, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'style']
|
||||||
|
});
|
||||||
|
|
||||||
|
// 强制设置初始宽度
|
||||||
|
(sidebar as HTMLElement).style.width = '230px';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 延迟执行,等待侧边栏渲染
|
||||||
|
setTimeout(preventSidebarCollapse, 500);
|
||||||
|
setTimeout(preventSidebarCollapse, 1000);
|
||||||
|
setTimeout(preventSidebarCollapse, 2000);
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -37,3 +150,7 @@ const tokenTheme = computed(() => {
|
|||||||
</App>
|
</App>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Styles can be added here if needed */
|
||||||
|
</style>
|
||||||
|
|||||||
14
apps/web-antd/src/custom.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* Hide FinWise Pro parent menu and move children */
|
||||||
|
.vben-sub-menu:has(.vben-sub-menu-content__title:is(:contains("FinWise Pro"), :contains("💎"))) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternative approach using attribute selector if :contains doesn't work */
|
||||||
|
.vben-sub-menu .vben-sub-menu-content__title {
|
||||||
|
/* We'll use JavaScript to add a data attribute to the parent */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mark submenu for hiding */
|
||||||
|
.vben-sub-menu[data-hide-finwise="true"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { initPreferences } from '@vben/preferences';
|
|||||||
import { unmountGlobalLoading } from '@vben/utils';
|
import { unmountGlobalLoading } from '@vben/utils';
|
||||||
|
|
||||||
import { overridesPreferences } from './preferences';
|
import { overridesPreferences } from './preferences';
|
||||||
|
import './custom.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用初始化完成之后再进行页面加载渲染
|
* 应用初始化完成之后再进行页面加载渲染
|
||||||
@@ -29,3 +30,68 @@ async function initApplication() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initApplication();
|
initApplication();
|
||||||
|
|
||||||
|
// Flatten FinWise Pro menu globally
|
||||||
|
function flattenFinWiseProMenu() {
|
||||||
|
const submenus = document.querySelectorAll('.vben-sub-menu');
|
||||||
|
let finwiseMenu: Element | null = null;
|
||||||
|
|
||||||
|
submenus.forEach(menu => {
|
||||||
|
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
|
||||||
|
if (titleEl?.textContent?.includes('FinWise Pro')) {
|
||||||
|
finwiseMenu = menu;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finwiseMenu) return;
|
||||||
|
|
||||||
|
const parentMenu = finwiseMenu.parentElement;
|
||||||
|
const childrenUL = finwiseMenu.querySelector('.vben-menu');
|
||||||
|
|
||||||
|
if (!childrenUL || !parentMenu) return;
|
||||||
|
|
||||||
|
// Check if already processed
|
||||||
|
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
|
||||||
|
|
||||||
|
// Move all children to the parent menu
|
||||||
|
const children = Array.from(childrenUL.children);
|
||||||
|
children.forEach(child => {
|
||||||
|
parentMenu.insertBefore(child, finwiseMenu);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark for hiding via CSS and hide directly
|
||||||
|
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
|
||||||
|
(finwiseMenu as HTMLElement).style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DOM to be ready, then run the flatten function
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Run multiple times with delays to catch menu rendering
|
||||||
|
setTimeout(() => flattenFinWiseProMenu(), 500);
|
||||||
|
setTimeout(() => flattenFinWiseProMenu(), 1000);
|
||||||
|
setTimeout(() => flattenFinWiseProMenu(), 2000);
|
||||||
|
setTimeout(() => flattenFinWiseProMenu(), 3000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// DOM is already loaded
|
||||||
|
setTimeout(() => flattenFinWiseProMenu(), 500);
|
||||||
|
setTimeout(() => flattenFinWiseProMenu(), 1000);
|
||||||
|
setTimeout(() => flattenFinWiseProMenu(), 2000);
|
||||||
|
setTimeout(() => flattenFinWiseProMenu(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for DOM changes
|
||||||
|
setTimeout(() => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setTimeout(flattenFinWiseProMenu, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = document.body;
|
||||||
|
if (body) {
|
||||||
|
observer.observe(body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|||||||
@@ -11,5 +11,15 @@ export const overridesPreferences = defineOverridesPreferences({
|
|||||||
name: 'Vben Admin Antd', // 固定网站名称,不随语言改变
|
name: 'Vben Admin Antd', // 固定网站名称,不随语言改变
|
||||||
locale: 'zh-CN', // 默认语言为中文
|
locale: 'zh-CN', // 默认语言为中文
|
||||||
theme: 'dark', // 默认深色主题
|
theme: 'dark', // 默认深色主题
|
||||||
|
defaultHomePath: '/dashboard-finance', // 默认首页改为财务仪表板
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
collapsed: false, // 侧边栏默认展开
|
||||||
|
expandOnHover: false, // 禁用悬停展开
|
||||||
|
enable: true, // 启用侧边栏
|
||||||
|
width: 230, // 设置侧边栏宽度
|
||||||
|
collapsedWidth: 230, // 收起时的宽度也设为正常宽度,防止收起
|
||||||
|
extraCollapse: false, // 禁用额外的收起功能
|
||||||
|
collapsedButton: false, // 禁用折叠按钮
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,4 +34,46 @@ const resetRoutes = () => resetStaticRoutes(router, routes);
|
|||||||
// 创建路由守卫
|
// 创建路由守卫
|
||||||
createRouterGuard(router);
|
createRouterGuard(router);
|
||||||
|
|
||||||
|
// Flatten FinWise Pro menu after each route change
|
||||||
|
router.afterEach(() => {
|
||||||
|
const flattenFinWiseProMenu = () => {
|
||||||
|
const submenus = document.querySelectorAll('.vben-sub-menu');
|
||||||
|
let finwiseMenu: Element | null = null;
|
||||||
|
|
||||||
|
submenus.forEach(menu => {
|
||||||
|
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
|
||||||
|
if (titleEl?.textContent?.includes('FinWise Pro')) {
|
||||||
|
finwiseMenu = menu;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finwiseMenu) return;
|
||||||
|
|
||||||
|
const parentMenu = finwiseMenu.parentElement;
|
||||||
|
const childrenUL = finwiseMenu.querySelector('.vben-menu');
|
||||||
|
|
||||||
|
if (!childrenUL || !parentMenu) return;
|
||||||
|
|
||||||
|
// Check if already processed
|
||||||
|
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
|
||||||
|
|
||||||
|
// Move all children to the parent menu
|
||||||
|
const children = Array.from(childrenUL.children);
|
||||||
|
children.forEach(child => {
|
||||||
|
parentMenu.insertBefore(child, finwiseMenu);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark for hiding via CSS and hide directly
|
||||||
|
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
|
||||||
|
(finwiseMenu as HTMLElement).style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run multiple times to catch menu rendering
|
||||||
|
setTimeout(flattenFinWiseProMenu, 100);
|
||||||
|
setTimeout(flattenFinWiseProMenu, 300);
|
||||||
|
setTimeout(flattenFinWiseProMenu, 500);
|
||||||
|
setTimeout(flattenFinWiseProMenu, 1000);
|
||||||
|
setTimeout(flattenFinWiseProMenu, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
export { resetRoutes, router };
|
export { resetRoutes, router };
|
||||||
|
|||||||
106
apps/web-antd/src/router/routes/modules/business-modules.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
name: 'FinanceDashboard',
|
||||||
|
path: '/dashboard-finance',
|
||||||
|
alias: ['/finance/dashboard'],
|
||||||
|
component: () => import('#/views/finance/dashboard/index.vue'),
|
||||||
|
meta: {
|
||||||
|
affixTab: true,
|
||||||
|
icon: 'mdi:chart-box',
|
||||||
|
order: 1,
|
||||||
|
title: '📊 财务仪表板',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FinanceTransactions',
|
||||||
|
path: '/transactions',
|
||||||
|
alias: ['/finance/transactions'],
|
||||||
|
component: () => import('#/views/finance/transactions/index.vue'),
|
||||||
|
meta: {
|
||||||
|
icon: 'mdi:swap-horizontal',
|
||||||
|
order: 2,
|
||||||
|
title: '💰 交易管理',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FinanceAccounts',
|
||||||
|
path: '/accounts',
|
||||||
|
alias: ['/finance/accounts'],
|
||||||
|
component: () => import('#/views/finance/accounts/index.vue'),
|
||||||
|
meta: {
|
||||||
|
icon: 'mdi:account-multiple',
|
||||||
|
order: 3,
|
||||||
|
title: '🏦 账户管理',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FinanceCategories',
|
||||||
|
path: '/categories',
|
||||||
|
alias: ['/finance/categories'],
|
||||||
|
component: () => import('#/views/finance/categories/index.vue'),
|
||||||
|
meta: {
|
||||||
|
icon: 'mdi:tag-multiple',
|
||||||
|
order: 4,
|
||||||
|
title: '🏷️ 分类管理',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FinanceBudgets',
|
||||||
|
path: '/budgets',
|
||||||
|
alias: ['/finance/budgets'],
|
||||||
|
component: () => import('#/views/finance/budgets/index.vue'),
|
||||||
|
meta: {
|
||||||
|
icon: 'mdi:target',
|
||||||
|
order: 5,
|
||||||
|
title: '🎯 预算管理',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FinanceStatistics',
|
||||||
|
path: '/statistics',
|
||||||
|
alias: ['/finance/statistics'],
|
||||||
|
component: () => import('#/views/finance/statistics/index.vue'),
|
||||||
|
meta: {
|
||||||
|
icon: 'mdi:chart-box-outline',
|
||||||
|
order: 6,
|
||||||
|
title: '📊 财务统计',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FinanceReports',
|
||||||
|
path: '/reports',
|
||||||
|
alias: ['/finance/reports'],
|
||||||
|
component: () => import('#/views/finance/reports/index.vue'),
|
||||||
|
meta: {
|
||||||
|
icon: 'mdi:chart-line',
|
||||||
|
order: 7,
|
||||||
|
title: '📈 报表分析',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FinanceTools',
|
||||||
|
path: '/tools',
|
||||||
|
alias: ['/finance/tools'],
|
||||||
|
component: () => import('#/views/finance/tools/index.vue'),
|
||||||
|
meta: {
|
||||||
|
icon: 'mdi:tools',
|
||||||
|
order: 8,
|
||||||
|
title: '🛠️ 财务工具',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FinanceSettings',
|
||||||
|
path: '/fin-settings',
|
||||||
|
alias: ['/finance/settings'],
|
||||||
|
component: () => import('#/views/finance/settings/index.vue'),
|
||||||
|
meta: {
|
||||||
|
icon: 'mdi:cog',
|
||||||
|
order: 9,
|
||||||
|
title: '⚙️ 系统设置',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routes;
|
||||||
@@ -1,37 +1,10 @@
|
|||||||
import type { RouteRecordRaw } from 'vue-router';
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
|
||||||
import { $t } from '#/locales';
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
meta: {
|
name: 'Workspace',
|
||||||
icon: 'lucide:layout-dashboard',
|
path: '/workspace',
|
||||||
order: -1,
|
redirect: '/dashboard-finance',
|
||||||
title: $t('page.dashboard.title'),
|
|
||||||
},
|
|
||||||
name: 'Dashboard',
|
|
||||||
path: '/dashboard',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'Analytics',
|
|
||||||
path: '/analytics',
|
|
||||||
component: () => import('#/views/dashboard/analytics/index.vue'),
|
|
||||||
meta: {
|
|
||||||
affixTab: true,
|
|
||||||
icon: 'lucide:area-chart',
|
|
||||||
title: $t('page.dashboard.analytics'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Workspace',
|
|
||||||
path: '/workspace',
|
|
||||||
component: () => import('#/views/dashboard/workspace/index.vue'),
|
|
||||||
meta: {
|
|
||||||
icon: 'carbon:workspace',
|
|
||||||
title: $t('page.dashboard.workspace'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import type { RouteRecordRaw } from 'vue-router';
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
|
||||||
{
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:bank',
|
|
||||||
order: 1,
|
|
||||||
title: '💎 FinWise Pro',
|
|
||||||
},
|
|
||||||
name: 'FinWisePro',
|
|
||||||
path: '/finance',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'FinanceDashboard',
|
|
||||||
path: 'dashboard',
|
|
||||||
component: () => import('#/views/finance/dashboard/index.vue'),
|
|
||||||
meta: {
|
|
||||||
affixTab: true,
|
|
||||||
icon: 'mdi:chart-box',
|
|
||||||
title: '📊 财务仪表板',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'TransactionManagement',
|
|
||||||
path: 'transactions',
|
|
||||||
component: () => import('#/views/finance/transactions/index.vue'),
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:swap-horizontal',
|
|
||||||
title: '💰 交易管理',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'AccountManagement',
|
|
||||||
path: 'accounts',
|
|
||||||
component: () => import('#/views/finance/accounts/index.vue'),
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:account-multiple',
|
|
||||||
title: '🏦 账户管理',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'CategoryManagement',
|
|
||||||
path: 'categories',
|
|
||||||
component: () => import('#/views/finance/categories/index.vue'),
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:tag-multiple',
|
|
||||||
title: '🏷️ 分类管理',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'BudgetManagement',
|
|
||||||
path: 'budgets',
|
|
||||||
component: () => import('#/views/finance/budgets/index.vue'),
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:target',
|
|
||||||
title: '🎯 预算管理',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ReportsAnalytics',
|
|
||||||
path: 'reports',
|
|
||||||
component: () => import('#/views/finance/reports/index.vue'),
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:chart-line',
|
|
||||||
title: '📈 报表分析',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FinanceTools',
|
|
||||||
path: 'tools',
|
|
||||||
component: () => import('#/views/finance/tools/index.vue'),
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:tools',
|
|
||||||
title: '🛠️ 财务工具',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'FinanceSettings',
|
|
||||||
path: 'settings',
|
|
||||||
component: () => import('#/views/finance/settings/index.vue'),
|
|
||||||
meta: {
|
|
||||||
icon: 'mdi:cog',
|
|
||||||
title: '⚙️ 系统设置',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default routes;
|
|
||||||
325
apps/web-antd/src/store/finance.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { FinanceApi } from '#/api/core/finance';
|
||||||
|
|
||||||
|
export const useFinanceStore = defineStore('finance', () => {
|
||||||
|
// 状态
|
||||||
|
const currencies = ref<FinanceApi.Currency[]>([]);
|
||||||
|
const incomeCategories = ref<FinanceApi.Category[]>([]);
|
||||||
|
const expenseCategories = ref<FinanceApi.Category[]>([]);
|
||||||
|
const accounts = ref<FinanceApi.Account[]>([]);
|
||||||
|
const exchangeRates = ref<FinanceApi.ExchangeRate[]>([]);
|
||||||
|
const transactions = ref<FinanceApi.Transaction[]>([]);
|
||||||
|
const budgets = ref<FinanceApi.Budget[]>([]);
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const loading = ref({
|
||||||
|
currencies: false,
|
||||||
|
categories: false,
|
||||||
|
accounts: false,
|
||||||
|
exchangeRates: false,
|
||||||
|
transactions: false,
|
||||||
|
budgets: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取货币列表
|
||||||
|
async function fetchCurrencies() {
|
||||||
|
loading.value.currencies = true;
|
||||||
|
try {
|
||||||
|
currencies.value = await FinanceApi.getCurrencies();
|
||||||
|
} finally {
|
||||||
|
loading.value.currencies = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分类列表
|
||||||
|
async function fetchCategories() {
|
||||||
|
loading.value.categories = true;
|
||||||
|
try {
|
||||||
|
const [income, expense] = await Promise.all([
|
||||||
|
FinanceApi.getCategories({ type: 'income' }),
|
||||||
|
FinanceApi.getCategories({ type: 'expense' }),
|
||||||
|
]);
|
||||||
|
incomeCategories.value = income;
|
||||||
|
expenseCategories.value = expense;
|
||||||
|
} finally {
|
||||||
|
loading.value.categories = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建分类
|
||||||
|
async function createCategory(data: {
|
||||||
|
name: string;
|
||||||
|
type: 'income' | 'expense';
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
const category = await FinanceApi.createCategory(data);
|
||||||
|
if (!category) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (category.type === 'income') {
|
||||||
|
incomeCategories.value.push(category);
|
||||||
|
} else {
|
||||||
|
expenseCategories.value.push(category);
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新分类
|
||||||
|
async function updateCategory(
|
||||||
|
id: number,
|
||||||
|
data: {
|
||||||
|
name?: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const category = await FinanceApi.updateCategory(id, data);
|
||||||
|
if (!category) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const list =
|
||||||
|
category.type === 'income'
|
||||||
|
? incomeCategories.value
|
||||||
|
: expenseCategories.value;
|
||||||
|
const index = list.findIndex((c) => c.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
list[index] = category;
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除分类
|
||||||
|
async function deleteCategory(id: number) {
|
||||||
|
await FinanceApi.deleteCategory(id);
|
||||||
|
// 从本地列表中移除
|
||||||
|
let index = incomeCategories.value.findIndex((c) => c.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
incomeCategories.value.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
index = expenseCategories.value.findIndex((c) => c.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
expenseCategories.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账户列表
|
||||||
|
async function fetchAccounts(currency?: string) {
|
||||||
|
loading.value.accounts = true;
|
||||||
|
try {
|
||||||
|
accounts.value = await FinanceApi.getAccounts(
|
||||||
|
currency ? { currency } : undefined,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
loading.value.accounts = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取汇率
|
||||||
|
async function fetchExchangeRates() {
|
||||||
|
loading.value.exchangeRates = true;
|
||||||
|
try {
|
||||||
|
exchangeRates.value = await FinanceApi.getExchangeRates();
|
||||||
|
} finally {
|
||||||
|
loading.value.exchangeRates = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取交易列表
|
||||||
|
async function fetchTransactions() {
|
||||||
|
loading.value.transactions = true;
|
||||||
|
try {
|
||||||
|
transactions.value = await FinanceApi.getTransactions();
|
||||||
|
} finally {
|
||||||
|
loading.value.transactions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建交易
|
||||||
|
async function createTransaction(data: FinanceApi.CreateTransactionParams) {
|
||||||
|
const transaction = await FinanceApi.createTransaction(data);
|
||||||
|
// 添加到本地列表
|
||||||
|
transactions.value.unshift(transaction);
|
||||||
|
// 重新获取账户余额
|
||||||
|
await fetchAccounts();
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新交易
|
||||||
|
async function updateTransaction(
|
||||||
|
id: number,
|
||||||
|
data: Partial<FinanceApi.CreateTransactionParams>,
|
||||||
|
) {
|
||||||
|
const transaction = await FinanceApi.updateTransaction(id, data);
|
||||||
|
// 更新本地列表
|
||||||
|
const index = transactions.value.findIndex((t) => t.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
transactions.value[index] = transaction;
|
||||||
|
}
|
||||||
|
// 重新获取账户余额
|
||||||
|
await fetchAccounts();
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软删除交易
|
||||||
|
async function softDeleteTransaction(id: number) {
|
||||||
|
await FinanceApi.deleteTransaction(id);
|
||||||
|
// 更新本地列表中的删除状态
|
||||||
|
const index = transactions.value.findIndex((t) => t.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
transactions.value[index] = {
|
||||||
|
...transactions.value[index],
|
||||||
|
isDeleted: true,
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 重新获取账户余额
|
||||||
|
await fetchAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复交易
|
||||||
|
async function restoreTransaction(id: number) {
|
||||||
|
const transaction = await FinanceApi.restoreTransaction(id);
|
||||||
|
// 更新本地列表
|
||||||
|
const index = transactions.value.findIndex((t) => t.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
transactions.value[index] = transaction;
|
||||||
|
}
|
||||||
|
// 重新获取账户余额
|
||||||
|
await fetchAccounts();
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据货币代码获取货币信息
|
||||||
|
function getCurrencyByCode(code: string) {
|
||||||
|
return currencies.value.find((c) => c.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据账户ID获取账户信息
|
||||||
|
function getAccountById(id: number) {
|
||||||
|
return accounts.value.find((a) => a.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据分类ID获取分类信息
|
||||||
|
function getCategoryById(id: number) {
|
||||||
|
return [...incomeCategories.value, ...expenseCategories.value].find(
|
||||||
|
(c) => c.id === id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取汇率
|
||||||
|
function getExchangeRate(from: string, to: string) {
|
||||||
|
return exchangeRates.value.find(
|
||||||
|
(r) => r.fromCurrency === from && r.toCurrency === to,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据货币过滤账户
|
||||||
|
function getAccountsByCurrency(currency: string) {
|
||||||
|
return accounts.value.filter((a) => a.currency === currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取预算列表
|
||||||
|
async function fetchBudgets() {
|
||||||
|
loading.value.budgets = true;
|
||||||
|
try {
|
||||||
|
budgets.value = await FinanceApi.getBudgets();
|
||||||
|
} finally {
|
||||||
|
loading.value.budgets = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建预算
|
||||||
|
async function createBudget(data: FinanceApi.CreateBudgetParams) {
|
||||||
|
const budget = await FinanceApi.createBudget(data);
|
||||||
|
budgets.value.push(budget);
|
||||||
|
return budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新预算
|
||||||
|
async function updateBudget(
|
||||||
|
id: number,
|
||||||
|
data: Partial<FinanceApi.CreateBudgetParams>,
|
||||||
|
) {
|
||||||
|
const budget = await FinanceApi.updateBudget(id, data);
|
||||||
|
const index = budgets.value.findIndex((b) => b.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
budgets.value[index] = budget;
|
||||||
|
}
|
||||||
|
return budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除预算
|
||||||
|
async function deleteBudget(id: number) {
|
||||||
|
await FinanceApi.deleteBudget(id);
|
||||||
|
const index = budgets.value.findIndex((b) => b.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
budgets.value[index] = {
|
||||||
|
...budgets.value[index],
|
||||||
|
isDeleted: true,
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复预算
|
||||||
|
async function restoreBudget(id: number) {
|
||||||
|
const budget = await FinanceApi.restoreBudget(id);
|
||||||
|
const index = budgets.value.findIndex((b) => b.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
budgets.value[index] = budget;
|
||||||
|
}
|
||||||
|
return budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化所有数据
|
||||||
|
async function initializeData() {
|
||||||
|
await Promise.all([
|
||||||
|
fetchCurrencies(),
|
||||||
|
fetchCategories(),
|
||||||
|
fetchAccounts(),
|
||||||
|
fetchExchangeRates(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
currencies,
|
||||||
|
incomeCategories,
|
||||||
|
expenseCategories,
|
||||||
|
accounts,
|
||||||
|
exchangeRates,
|
||||||
|
transactions,
|
||||||
|
budgets,
|
||||||
|
loading,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
fetchCurrencies,
|
||||||
|
fetchCategories,
|
||||||
|
createCategory,
|
||||||
|
updateCategory,
|
||||||
|
deleteCategory,
|
||||||
|
fetchAccounts,
|
||||||
|
fetchExchangeRates,
|
||||||
|
fetchTransactions,
|
||||||
|
createTransaction,
|
||||||
|
updateTransaction,
|
||||||
|
softDeleteTransaction,
|
||||||
|
restoreTransaction,
|
||||||
|
fetchBudgets,
|
||||||
|
createBudget,
|
||||||
|
updateBudget,
|
||||||
|
deleteBudget,
|
||||||
|
restoreBudget,
|
||||||
|
getCurrencyByCode,
|
||||||
|
getAccountById,
|
||||||
|
getCategoryById,
|
||||||
|
getExchangeRate,
|
||||||
|
getAccountsByCurrency,
|
||||||
|
initializeData,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -78,13 +78,13 @@ const formSchema = computed((): VbenFormSchema[] => {
|
|||||||
label: $t('authentication.password'),
|
label: $t('authentication.password'),
|
||||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
component: markRaw(SliderCaptcha),
|
// component: markRaw(SliderCaptcha),
|
||||||
fieldName: 'captcha',
|
// fieldName: 'captcha',
|
||||||
rules: z.boolean().refine((value) => value, {
|
// rules: z.boolean().refine((value) => value, {
|
||||||
message: $t('authentication.verifyRequiredTip'),
|
// message: $t('authentication.verifyRequiredTip'),
|
||||||
}),
|
// }),
|
||||||
},
|
// },
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Fallback } from '@vben/common-ui';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
defineOptions({ name: 'Fallback404Demo' });
|
defineOptions({ name: 'Fallback404Demo' });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function handleBackHome() {
|
||||||
|
router.push('/dashboard-finance');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Fallback status="404" />
|
<div class="flex h-screen w-screen flex-col items-center justify-center bg-background">
|
||||||
|
<h1 class="mb-4 text-6xl font-bold text-foreground">404</h1>
|
||||||
|
<h2 class="mb-2 text-2xl font-semibold text-foreground">哎呀!未找到页面</h2>
|
||||||
|
<p class="mb-8 text-muted-foreground">抱歉,我们无法找到您要找的页面。</p>
|
||||||
|
<button
|
||||||
|
@click="handleBackHome"
|
||||||
|
class="rounded-md bg-primary px-6 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
WorkbenchTrendItem,
|
WorkbenchTrendItem,
|
||||||
} from '@vben/common-ui';
|
} from '@vben/common-ui';
|
||||||
|
|
||||||
import { ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -21,208 +21,481 @@ import { preferences } from '@vben/preferences';
|
|||||||
import { useUserStore } from '@vben/stores';
|
import { useUserStore } from '@vben/stores';
|
||||||
import { openWindow } from '@vben/utils';
|
import { openWindow } from '@vben/utils';
|
||||||
|
|
||||||
|
import { Modal, Form, Input, Select, DatePicker, InputNumber, message, Radio, Space, Button, Row, Col, Switch } from 'ant-design-vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { useFinanceStore } from '#/store/finance';
|
||||||
|
|
||||||
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
|
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const financeStore = useFinanceStore();
|
||||||
|
|
||||||
// 这是一个示例数据,实际项目中需要根据实际情况进行调整
|
// 初始化财务数据
|
||||||
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转
|
onMounted(async () => {
|
||||||
// 例如:url: /dashboard/workspace
|
await financeStore.initializeData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 快速记账弹窗
|
||||||
|
const quickAddVisible = ref(false);
|
||||||
|
const transactionType = ref<'income' | 'expense'>('expense');
|
||||||
|
const formRef = ref();
|
||||||
|
const formState = ref({
|
||||||
|
currency: 'CNY', // 默认人民币
|
||||||
|
quantity: 1, // 数量,默认1
|
||||||
|
unitPrice: null, // 单价
|
||||||
|
amount: null, // 总金额(自动计算或手动输入)
|
||||||
|
weight: null, // 重量(可选)
|
||||||
|
weightUnit: 'kg', // 重量单位,默认千克
|
||||||
|
category: undefined,
|
||||||
|
account: undefined,
|
||||||
|
date: null,
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 是否使用单价×数量计算模式
|
||||||
|
const useQuantityMode = ref(false);
|
||||||
|
|
||||||
|
// 当前选中的日期类型
|
||||||
|
const selectedDateType = ref<'today' | 'yesterday' | 'week' | 'month' | 'custom'>('today');
|
||||||
|
|
||||||
|
// 字段触摸状态(用于判断是否显示验证提示)
|
||||||
|
const touchedFields = ref({
|
||||||
|
category: false,
|
||||||
|
account: false,
|
||||||
|
amount: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听单价和数量变化,自动计算总金额
|
||||||
|
watch([() => formState.value.unitPrice, () => formState.value.quantity], ([unitPrice, quantity]) => {
|
||||||
|
if (useQuantityMode.value && unitPrice && quantity) {
|
||||||
|
formState.value.amount = unitPrice * quantity;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换计算模式
|
||||||
|
const toggleQuantityMode = (enabled: boolean) => {
|
||||||
|
useQuantityMode.value = enabled;
|
||||||
|
if (enabled) {
|
||||||
|
// 如果当前有金额,反推单价
|
||||||
|
if (formState.value.amount && formState.value.quantity) {
|
||||||
|
formState.value.unitPrice = formState.value.amount / formState.value.quantity;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 关闭模式时清空单价和数量
|
||||||
|
formState.value.quantity = 1;
|
||||||
|
formState.value.unitPrice = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算属性: 当前分类列表
|
||||||
|
const currentCategories = computed(() => {
|
||||||
|
return transactionType.value === 'income'
|
||||||
|
? financeStore.incomeCategories
|
||||||
|
: financeStore.expenseCategories;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性: 根据选择的货币过滤账户
|
||||||
|
const filteredAccounts = computed(() => {
|
||||||
|
return financeStore.getAccountsByCurrency(formState.value.currency);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性: 获取当前货币符号
|
||||||
|
const currentCurrencySymbol = computed(() => {
|
||||||
|
const currency = financeStore.getCurrencyByCode(formState.value.currency);
|
||||||
|
return currency?.symbol || '¥';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听货币变化,重置账户选择
|
||||||
|
watch(() => formState.value.currency, () => {
|
||||||
|
formState.value.account = undefined;
|
||||||
|
touchedFields.value.account = true; // 标记账户字段为已触摸
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听账户变化,保存到localStorage
|
||||||
|
watch(() => formState.value.account, (newAccountId) => {
|
||||||
|
if (newAccountId && transactionType.value) {
|
||||||
|
const storageKey = transactionType.value === 'income'
|
||||||
|
? 'lastWorkspaceIncomeAccountId'
|
||||||
|
: 'lastWorkspaceExpenseAccountId';
|
||||||
|
localStorage.setItem(storageKey, String(newAccountId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 打开快速记账弹窗
|
||||||
|
const openQuickAdd = (type: 'income' | 'expense') => {
|
||||||
|
transactionType.value = type;
|
||||||
|
quickAddVisible.value = true;
|
||||||
|
|
||||||
|
// 读取上次选择的账户
|
||||||
|
const storageKey = type === 'income'
|
||||||
|
? 'lastWorkspaceIncomeAccountId'
|
||||||
|
: 'lastWorkspaceExpenseAccountId';
|
||||||
|
const lastAccountId = localStorage.getItem(storageKey);
|
||||||
|
const accountId = lastAccountId ? Number(lastAccountId) : undefined;
|
||||||
|
|
||||||
|
// 重置表单,日期默认为今天,货币默认为CNY
|
||||||
|
formState.value = {
|
||||||
|
currency: 'CNY',
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: null,
|
||||||
|
amount: null,
|
||||||
|
weight: null,
|
||||||
|
weightUnit: 'kg',
|
||||||
|
category: undefined,
|
||||||
|
account: accountId,
|
||||||
|
date: dayjs(),
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置计算模式
|
||||||
|
useQuantityMode.value = false;
|
||||||
|
|
||||||
|
// 重置触摸状态
|
||||||
|
touchedFields.value = {
|
||||||
|
category: false,
|
||||||
|
account: false,
|
||||||
|
amount: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 日期快捷方式
|
||||||
|
const setDate = (type: 'today' | 'yesterday' | 'week' | 'month') => {
|
||||||
|
selectedDateType.value = type;
|
||||||
|
switch (type) {
|
||||||
|
case 'today':
|
||||||
|
formState.value.date = dayjs();
|
||||||
|
break;
|
||||||
|
case 'yesterday':
|
||||||
|
formState.value.date = dayjs().subtract(1, 'day');
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
formState.value.date = dayjs().startOf('week');
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
formState.value.date = dayjs().startOf('month');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听日期手动变化,设置为自定义
|
||||||
|
watch(() => formState.value.date, (newDate) => {
|
||||||
|
if (!newDate) return;
|
||||||
|
|
||||||
|
const today = dayjs();
|
||||||
|
const yesterday = dayjs().subtract(1, 'day');
|
||||||
|
const weekStart = dayjs().startOf('week');
|
||||||
|
const monthStart = dayjs().startOf('month');
|
||||||
|
|
||||||
|
if (newDate.isSame(today, 'day')) {
|
||||||
|
selectedDateType.value = 'today';
|
||||||
|
} else if (newDate.isSame(yesterday, 'day')) {
|
||||||
|
selectedDateType.value = 'yesterday';
|
||||||
|
} else if (newDate.isSame(weekStart, 'day')) {
|
||||||
|
selectedDateType.value = 'week';
|
||||||
|
} else if (newDate.isSame(monthStart, 'day')) {
|
||||||
|
selectedDateType.value = 'month';
|
||||||
|
} else {
|
||||||
|
selectedDateType.value = 'custom';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取日期类型对应的颜色
|
||||||
|
const getDateTypeColor = (type: string) => {
|
||||||
|
const colors = {
|
||||||
|
today: '#52c41a', // 绿色 - 今天
|
||||||
|
yesterday: '#1890ff', // 蓝色 - 昨天
|
||||||
|
week: '#722ed1', // 紫色 - 本周
|
||||||
|
month: '#fa8c16', // 橙色 - 本月
|
||||||
|
custom: '#8c8c8c', // 灰色 - 自定义
|
||||||
|
};
|
||||||
|
return colors[type] || colors.custom;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算属性:检查必填字段是否有错误
|
||||||
|
const fieldErrors = computed(() => ({
|
||||||
|
category: touchedFields.value.category && !formState.value.category,
|
||||||
|
account: touchedFields.value.account && !formState.value.account,
|
||||||
|
amount: touchedFields.value.amount && (!formState.value.amount || formState.value.amount <= 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 提交记账
|
||||||
|
const handleQuickAdd = async () => {
|
||||||
|
try {
|
||||||
|
// 标记所有必填字段为已触摸,以便显示验证错误
|
||||||
|
touchedFields.value = {
|
||||||
|
category: true,
|
||||||
|
account: true,
|
||||||
|
amount: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await formRef.value?.validate();
|
||||||
|
|
||||||
|
console.log('开始创建交易,表单数据:', formState.value);
|
||||||
|
console.log('交易类型:', transactionType.value);
|
||||||
|
|
||||||
|
// 调用API创建交易
|
||||||
|
const transaction = await financeStore.createTransaction({
|
||||||
|
type: transactionType.value,
|
||||||
|
amount: formState.value.amount!,
|
||||||
|
currency: formState.value.currency,
|
||||||
|
categoryId: formState.value.category || undefined,
|
||||||
|
accountId: formState.value.account!,
|
||||||
|
transactionDate: formState.value.date.format('YYYY-MM-DD'),
|
||||||
|
description: formState.value.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('交易创建成功:', transaction);
|
||||||
|
message.success(`${transactionType.value === 'income' ? '收入' : '支出'}记录成功!`);
|
||||||
|
quickAddVisible.value = false;
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
formState.value = {
|
||||||
|
currency: 'CNY',
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: null,
|
||||||
|
amount: null,
|
||||||
|
weight: null,
|
||||||
|
weightUnit: 'kg',
|
||||||
|
category: undefined,
|
||||||
|
account: undefined,
|
||||||
|
date: null,
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置计算模式
|
||||||
|
useQuantityMode.value = false;
|
||||||
|
|
||||||
|
// 重置触摸状态
|
||||||
|
touchedFields.value = {
|
||||||
|
category: false,
|
||||||
|
account: false,
|
||||||
|
amount: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建交易失败:', error);
|
||||||
|
console.error('错误详情:', JSON.stringify(error, null, 2));
|
||||||
|
if (error?.errorFields) {
|
||||||
|
message.error('❌ 请填写所有必填项!');
|
||||||
|
} else {
|
||||||
|
message.error(`创建交易失败: ${error.message || '未知错误'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 财务管理快捷项目
|
||||||
const projectItems: WorkbenchProjectItem[] = [
|
const projectItems: WorkbenchProjectItem[] = [
|
||||||
{
|
{
|
||||||
color: '',
|
color: '#1890ff',
|
||||||
content: '不要等待机会,而要创造机会。',
|
content: '查看本月收支情况和财务概览',
|
||||||
date: '2021-04-01',
|
date: new Date().toLocaleDateString(),
|
||||||
group: '开源组',
|
group: '财务管理',
|
||||||
icon: 'carbon:logo-github',
|
icon: 'mdi:chart-box',
|
||||||
title: 'Github',
|
title: '财务仪表板',
|
||||||
url: 'https://github.com',
|
url: '/dashboard-finance',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: '#3fb27f',
|
color: '#52c41a',
|
||||||
content: '现在的你决定将来的你。',
|
content: '记录和管理所有收入支出交易',
|
||||||
date: '2021-04-01',
|
date: new Date().toLocaleDateString(),
|
||||||
group: '算法组',
|
group: '财务管理',
|
||||||
icon: 'ion:logo-vue',
|
icon: 'mdi:swap-horizontal',
|
||||||
title: 'Vue',
|
title: '交易管理',
|
||||||
url: 'https://vuejs.org',
|
url: '/transactions',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: '#e18525',
|
color: '#faad14',
|
||||||
content: '没有什么才能比努力更重要。',
|
content: '管理银行账户、信用卡等资产',
|
||||||
date: '2021-04-01',
|
date: new Date().toLocaleDateString(),
|
||||||
group: '上班摸鱼',
|
group: '财务管理',
|
||||||
icon: 'ion:logo-html5',
|
icon: 'mdi:account-multiple',
|
||||||
title: 'Html5',
|
title: '账户管理',
|
||||||
url: 'https://developer.mozilla.org/zh-CN/docs/Web/HTML',
|
url: '/accounts',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: '#bf0c2c',
|
color: '#722ed1',
|
||||||
content: '热情和欲望可以突破一切难关。',
|
content: '查看和分析各类财务报表',
|
||||||
date: '2021-04-01',
|
date: new Date().toLocaleDateString(),
|
||||||
group: 'UI',
|
group: '数据分析',
|
||||||
icon: 'ion:logo-angular',
|
icon: 'mdi:chart-line',
|
||||||
title: 'Angular',
|
title: '报表分析',
|
||||||
url: 'https://angular.io',
|
url: '/reports',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: '#00d8ff',
|
color: '#eb2f96',
|
||||||
content: '健康的身体是实现目标的基石。',
|
content: '设置和监控各项预算目标',
|
||||||
date: '2021-04-01',
|
date: new Date().toLocaleDateString(),
|
||||||
group: '技术牛',
|
group: '财务规划',
|
||||||
icon: 'bx:bxl-react',
|
icon: 'mdi:target',
|
||||||
title: 'React',
|
title: '预算管理',
|
||||||
url: 'https://reactjs.org',
|
url: '/budgets',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: '#EBD94E',
|
color: '#13c2c2',
|
||||||
content: '路是走出来的,而不是空想出来的。',
|
content: '管理收支分类标签',
|
||||||
date: '2021-04-01',
|
date: new Date().toLocaleDateString(),
|
||||||
group: '架构组',
|
group: '设置',
|
||||||
icon: 'ion:logo-javascript',
|
icon: 'mdi:tag-multiple',
|
||||||
title: 'Js',
|
title: '分类管理',
|
||||||
url: 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript',
|
url: '/categories',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 同样,这里的 url 也可以使用以 http 开头的外部链接
|
// 财务管理快捷导航
|
||||||
const quickNavItems: WorkbenchQuickNavItem[] = [
|
const quickNavItems: WorkbenchQuickNavItem[] = [
|
||||||
{
|
{
|
||||||
color: '#1fdaca',
|
color: '#1890ff',
|
||||||
icon: 'ion:home-outline',
|
icon: 'mdi:chart-box',
|
||||||
title: '首页',
|
title: '财务仪表板',
|
||||||
url: '/',
|
url: '/dashboard-finance',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: '#bf0c2c',
|
color: '#52c41a',
|
||||||
icon: 'ion:grid-outline',
|
icon: 'mdi:cash-plus',
|
||||||
title: '仪表盘',
|
title: '添加收入',
|
||||||
url: '/dashboard',
|
url: 'quick-add-income', // 特殊标识,用于触发弹窗
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: '#e18525',
|
color: '#f5222d',
|
||||||
icon: 'ion:layers-outline',
|
icon: 'mdi:cash-minus',
|
||||||
title: '组件',
|
title: '添加支出',
|
||||||
url: '/demos/features/icons',
|
url: 'quick-add-expense', // 特殊标识,用于触发弹窗
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: '#3fb27f',
|
color: '#faad14',
|
||||||
icon: 'ion:settings-outline',
|
icon: 'mdi:bank',
|
||||||
title: '系统管理',
|
title: '账户总览',
|
||||||
url: '/demos/features/login-expired', // 这里的 URL 是示例,实际项目中需要根据实际情况进行调整
|
url: '/accounts',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: '#4daf1bc9',
|
color: '#722ed1',
|
||||||
icon: 'ion:key-outline',
|
icon: 'mdi:chart-line',
|
||||||
title: '权限管理',
|
title: '财务报表',
|
||||||
url: '/demos/access/page-control',
|
url: '/reports',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
color: '#00d8ff',
|
color: '#13c2c2',
|
||||||
icon: 'ion:bar-chart-outline',
|
icon: 'mdi:cog',
|
||||||
title: '图表',
|
title: '系统设置',
|
||||||
url: '/analytics',
|
url: '/fin-settings',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const todoItems = ref<WorkbenchTodoItem[]>([
|
const todoItems = ref<WorkbenchTodoItem[]>([
|
||||||
{
|
{
|
||||||
completed: false,
|
completed: false,
|
||||||
content: `审查最近提交到Git仓库的前端代码,确保代码质量和规范。`,
|
content: `记录本月的水电费、房租等固定支出`,
|
||||||
date: '2024-07-30 11:00:00',
|
date: new Date().toLocaleDateString() + ' 18:00:00',
|
||||||
title: '审查前端代码提交',
|
title: '录入本月固定支出',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
completed: false,
|
||||||
|
content: `查看并调整各类别的预算设置,确保支出在可控范围内`,
|
||||||
|
date: new Date().toLocaleDateString() + ' 20:00:00',
|
||||||
|
title: '检查月度预算执行情况',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
completed: true,
|
completed: true,
|
||||||
content: `检查并优化系统性能,降低CPU使用率。`,
|
content: `完成本周的收入记录,包括工资和其他收入来源`,
|
||||||
date: '2024-07-30 11:00:00',
|
date: new Date().toLocaleDateString() + ' 10:00:00',
|
||||||
title: '系统性能优化',
|
title: '记录本周收入',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
completed: false,
|
completed: false,
|
||||||
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
|
content: `核对银行账户余额,确保系统数据与实际一致`,
|
||||||
date: '2024-07-30 11:00:00',
|
date: new Date().toLocaleDateString() + ' 15:00:00',
|
||||||
title: '安全检查',
|
title: '对账核对',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
completed: false,
|
completed: false,
|
||||||
content: `更新项目中的所有npm依赖包,确保使用最新版本。`,
|
content: `分析上月的支出报表,找出可以节省开支的地方`,
|
||||||
date: '2024-07-30 11:00:00',
|
date: new Date().toLocaleDateString() + ' 16:00:00',
|
||||||
title: '更新项目依赖',
|
title: '生成月度财务报表',
|
||||||
},
|
|
||||||
{
|
|
||||||
completed: false,
|
|
||||||
content: `修复用户报告的页面UI显示问题,确保在不同浏览器中显示一致。 `,
|
|
||||||
date: '2024-07-30 11:00:00',
|
|
||||||
title: '修复UI显示问题',
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const trendItems: WorkbenchTrendItem[] = [
|
const trendItems: WorkbenchTrendItem[] = [
|
||||||
{
|
{
|
||||||
avatar: 'svg:avatar-1',
|
avatar: 'svg:avatar-1',
|
||||||
content: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
|
content: `添加了一笔 <a>餐饮支出</a> ¥128.50`,
|
||||||
date: '刚刚',
|
date: '刚刚',
|
||||||
title: '威廉',
|
title: '系统记录',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
avatar: 'svg:avatar-2',
|
avatar: 'svg:avatar-2',
|
||||||
content: `关注了 <a>威廉</a> `,
|
content: `记录了 <a>工资收入</a> ¥12,000.00`,
|
||||||
date: '1个小时前',
|
date: '2小时前',
|
||||||
title: '艾文',
|
title: '收入记录',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
avatar: 'svg:avatar-3',
|
avatar: 'svg:avatar-3',
|
||||||
content: `发布了 <a>个人动态</a> `,
|
content: `更新了 <a>餐饮类别</a> 的预算额度`,
|
||||||
date: '1天前',
|
date: '今天 14:30',
|
||||||
title: '克里斯',
|
title: '预算调整',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
avatar: 'svg:avatar-4',
|
avatar: 'svg:avatar-4',
|
||||||
content: `发表文章 <a>如何编写一个Vite插件</a> `,
|
content: `创建了新的 <a>信用卡账户</a> `,
|
||||||
date: '2天前',
|
date: '今天 10:15',
|
||||||
title: 'Vben',
|
title: '账户管理',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
avatar: 'svg:avatar-1',
|
avatar: 'svg:avatar-1',
|
||||||
content: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
|
content: `生成了 <a>月度财务报表</a>`,
|
||||||
date: '3天前',
|
date: '昨天',
|
||||||
title: '皮特',
|
title: '报表生成',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
avatar: 'svg:avatar-2',
|
avatar: 'svg:avatar-2',
|
||||||
content: `关闭了问题 <a>如何运行项目</a> `,
|
content: `完成了 <a>账户对账</a> 操作`,
|
||||||
date: '1周前',
|
date: '昨天',
|
||||||
title: '杰克',
|
title: '对账记录',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
avatar: 'svg:avatar-3',
|
avatar: 'svg:avatar-3',
|
||||||
content: `发布了 <a>个人动态</a> `,
|
content: `添加了 <a>房租支出</a> ¥3,500.00`,
|
||||||
|
date: '2天前',
|
||||||
|
title: '支出记录',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
avatar: 'svg:avatar-4',
|
||||||
|
content: `设置了 <a>月度预算目标</a>`,
|
||||||
|
date: '3天前',
|
||||||
|
title: '预算规划',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
avatar: 'svg:avatar-1',
|
||||||
|
content: `优化了 <a>支出分类</a> 设置`,
|
||||||
date: '1周前',
|
date: '1周前',
|
||||||
title: '威廉',
|
title: '分类管理',
|
||||||
},
|
|
||||||
{
|
|
||||||
avatar: 'svg:avatar-4',
|
|
||||||
content: `推送了代码到 <a>Github</a>`,
|
|
||||||
date: '2021-04-01 20:00',
|
|
||||||
title: '威廉',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
avatar: 'svg:avatar-4',
|
|
||||||
content: `发表文章 <a>如何编写使用 Admin Vben</a> `,
|
|
||||||
date: '2021-03-01 20:00',
|
|
||||||
title: 'Vben',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// 这是一个示例方法,实际项目中需要根据实际情况进行调整
|
// 导航处理方法
|
||||||
// This is a sample method, adjust according to the actual project requirements
|
|
||||||
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||||
|
console.log('navTo被调用:', nav);
|
||||||
|
console.log('nav.url:', nav.url);
|
||||||
|
|
||||||
|
// 处理快速记账
|
||||||
|
if (nav.url === 'quick-add-income') {
|
||||||
|
console.log('打开收入弹窗');
|
||||||
|
openQuickAdd('income');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nav.url === 'quick-add-expense') {
|
||||||
|
console.log('打开支出弹窗');
|
||||||
|
openQuickAdd('expense');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理外部链接
|
||||||
if (nav.url?.startsWith('http')) {
|
if (nav.url?.startsWith('http')) {
|
||||||
openWindow(nav.url);
|
openWindow(nav.url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理内部路由
|
||||||
if (nav.url?.startsWith('/')) {
|
if (nav.url?.startsWith('/')) {
|
||||||
router.push(nav.url).catch((error) => {
|
router.push(nav.url).catch((error) => {
|
||||||
console.error('Navigation failed:', error);
|
console.error('Navigation failed:', error);
|
||||||
@@ -239,28 +512,344 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
|||||||
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
|
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
早安, {{ userStore.userInfo?.realName }}, 开始您一天的工作吧!
|
欢迎回来, {{ userStore.userInfo?.realName }}!开始管理您的财务吧 💰
|
||||||
|
</template>
|
||||||
|
<template #description>
|
||||||
|
让每一笔收支都清晰可见,让财务管理更轻松!
|
||||||
</template>
|
</template>
|
||||||
<template #description> 今日晴,20℃ - 32℃! </template>
|
|
||||||
</WorkbenchHeader>
|
</WorkbenchHeader>
|
||||||
|
|
||||||
<div class="mt-5 flex flex-col lg:flex-row">
|
<div class="mt-5 flex flex-col lg:flex-row">
|
||||||
<div class="mr-4 w-full lg:w-3/5">
|
<div class="mr-4 w-full lg:w-3/5">
|
||||||
<WorkbenchProject :items="projectItems" title="项目" @click="navTo" />
|
<WorkbenchProject :items="projectItems" title="财务功能快捷入口" @click="navTo" />
|
||||||
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
|
<WorkbenchTrends :items="trendItems" class="mt-5" title="最近财务活动" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full lg:w-2/5">
|
<div class="w-full lg:w-2/5">
|
||||||
<WorkbenchQuickNav
|
<WorkbenchQuickNav
|
||||||
:items="quickNavItems"
|
:items="quickNavItems"
|
||||||
class="mt-5 lg:mt-0"
|
class="mt-5 lg:mt-0"
|
||||||
title="快捷导航"
|
title="快捷操作"
|
||||||
@click="navTo"
|
@click="(item) => {
|
||||||
|
console.log('WorkbenchQuickNav click事件触发:', item);
|
||||||
|
navTo(item);
|
||||||
|
}"
|
||||||
/>
|
/>
|
||||||
<WorkbenchTodo :items="todoItems" class="mt-5" title="待办事项" />
|
<WorkbenchTodo :items="todoItems" class="mt-5" title="财务待办事项" />
|
||||||
<AnalysisChartCard class="mt-5" title="访问来源">
|
<AnalysisChartCard class="mt-5" title="本月收支概览">
|
||||||
<AnalyticsVisitsSource />
|
<AnalyticsVisitsSource />
|
||||||
</AnalysisChartCard>
|
</AnalysisChartCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 快速记账弹窗 -->
|
||||||
|
<Modal
|
||||||
|
:open="quickAddVisible"
|
||||||
|
:title="transactionType === 'income' ? '💰 添加收入' : '💸 添加支出'"
|
||||||
|
:width="900"
|
||||||
|
@ok="handleQuickAdd"
|
||||||
|
@cancel="() => { quickAddVisible = false; }"
|
||||||
|
@update:open="(val) => { quickAddVisible = val; }"
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
layout="vertical"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<Row :gutter="16">
|
||||||
|
<!-- 分类 -->
|
||||||
|
<Col :span="14">
|
||||||
|
<Form.Item
|
||||||
|
label="分类"
|
||||||
|
name="category"
|
||||||
|
:rules="[{ required: true, message: '请选择分类' }]"
|
||||||
|
:validate-status="fieldErrors.category ? 'error' : ''"
|
||||||
|
:help="fieldErrors.category ? '⚠️ 请选择一个分类' : ''"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:style="fieldErrors.category ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '8px' } : {}"
|
||||||
|
>
|
||||||
|
<Radio.Group
|
||||||
|
v-model:value="formState.category"
|
||||||
|
size="large"
|
||||||
|
button-style="solid"
|
||||||
|
class="category-radio-group"
|
||||||
|
@change="touchedFields.category = true"
|
||||||
|
>
|
||||||
|
<Radio.Button
|
||||||
|
v-for="category in currentCategories"
|
||||||
|
:key="category.id"
|
||||||
|
:value="category.id"
|
||||||
|
>
|
||||||
|
{{ category.icon }} {{ category.name }}
|
||||||
|
</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<!-- 项目名称 -->
|
||||||
|
<Col :span="10">
|
||||||
|
<Form.Item
|
||||||
|
label="项目名称"
|
||||||
|
name="description"
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
v-model:value="formState.description"
|
||||||
|
placeholder="请输入项目名称..."
|
||||||
|
:rows="4"
|
||||||
|
style="height: 100%"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<!-- 货币类型、账户和金额(放在一起) -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-4">
|
||||||
|
<Row :gutter="16">
|
||||||
|
<Col :span="12">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2">货币类型 <span class="text-red-500">*</span></label>
|
||||||
|
<Radio.Group
|
||||||
|
v-model:value="formState.currency"
|
||||||
|
size="large"
|
||||||
|
button-style="solid"
|
||||||
|
class="currency-radio-group"
|
||||||
|
>
|
||||||
|
<Radio.Button
|
||||||
|
v-for="currency in financeStore.currencies"
|
||||||
|
:key="currency.code"
|
||||||
|
:value="currency.code"
|
||||||
|
>
|
||||||
|
{{ currency.symbol }} {{ currency.name }}
|
||||||
|
</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col :span="12">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<label class="text-sm font-medium">按数量×单价计算</label>
|
||||||
|
<Switch v-model:checked="useQuantityMode" @change="toggleQuantityMode" />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<!-- 数量×单价模式 -->
|
||||||
|
<Row v-if="useQuantityMode" :gutter="16" class="mb-4">
|
||||||
|
<Col :span="8">
|
||||||
|
<label class="block text-sm font-medium mb-2">数量</label>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="formState.quantity"
|
||||||
|
:min="0.01"
|
||||||
|
:precision="2"
|
||||||
|
placeholder="数量"
|
||||||
|
style="width: 100%"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col :span="8">
|
||||||
|
<label class="block text-sm font-medium mb-2">单价</label>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="formState.unitPrice"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
placeholder="单价"
|
||||||
|
style="width: 100%"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<template #addonBefore>{{ currentCurrencySymbol }}</template>
|
||||||
|
</InputNumber>
|
||||||
|
</Col>
|
||||||
|
<Col :span="8">
|
||||||
|
<label class="block text-sm font-medium mb-2">
|
||||||
|
总金额 <span class="text-red-500">*</span>
|
||||||
|
<span v-if="fieldErrors.amount" class="text-red-500 text-xs ml-1">⚠️</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
:style="fieldErrors.amount ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '2px' } : {}"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="formState.amount"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
placeholder="自动计算"
|
||||||
|
style="width: 100%"
|
||||||
|
size="large"
|
||||||
|
:disabled="true"
|
||||||
|
@blur="touchedFields.amount = true"
|
||||||
|
>
|
||||||
|
<template #addonBefore>{{ currentCurrencySymbol }}</template>
|
||||||
|
</InputNumber>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<!-- 直接输入金额模式 -->
|
||||||
|
<Row v-else :gutter="16" class="mb-4">
|
||||||
|
<Col :span="24">
|
||||||
|
<label class="block text-sm font-medium mb-2">
|
||||||
|
金额 <span class="text-red-500">*</span>
|
||||||
|
<span v-if="fieldErrors.amount" class="text-red-500 text-xs ml-2">⚠️ 请输入金额</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
:style="fieldErrors.amount ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '2px' } : {}"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="formState.amount"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
placeholder="请输入金额"
|
||||||
|
style="width: 100%"
|
||||||
|
size="large"
|
||||||
|
@blur="touchedFields.amount = true"
|
||||||
|
>
|
||||||
|
<template #addonBefore>{{ currentCurrencySymbol }}</template>
|
||||||
|
</InputNumber>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<!-- 重量(可选) -->
|
||||||
|
<Row :gutter="16" class="mb-4">
|
||||||
|
<Col :span="16">
|
||||||
|
<label class="block text-sm font-medium mb-2">重量(可选)</label>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="formState.weight"
|
||||||
|
:min="0"
|
||||||
|
:precision="3"
|
||||||
|
placeholder="如需记录重量请输入"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col :span="8">
|
||||||
|
<label class="block text-sm font-medium mb-2">单位</label>
|
||||||
|
<Select v-model:value="formState.weightUnit" style="width: 100%">
|
||||||
|
<Select.Option value="kg">千克(kg)</Select.Option>
|
||||||
|
<Select.Option value="g">克(g)</Select.Option>
|
||||||
|
<Select.Option value="t">吨(t)</Select.Option>
|
||||||
|
<Select.Option value="lb">磅(lb)</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">
|
||||||
|
{{ transactionType === 'income' ? '收入账户' : '支出账户' }} <span class="text-red-500">*</span>
|
||||||
|
<span v-if="fieldErrors.account" class="text-red-500 text-xs ml-2">⚠️ 请选择账户</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
:style="fieldErrors.account ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '8px' } : {}"
|
||||||
|
>
|
||||||
|
<Radio.Group
|
||||||
|
v-model:value="formState.account"
|
||||||
|
size="large"
|
||||||
|
button-style="solid"
|
||||||
|
class="account-radio-group"
|
||||||
|
@change="touchedFields.account = true"
|
||||||
|
>
|
||||||
|
<Radio.Button
|
||||||
|
v-for="account in filteredAccounts"
|
||||||
|
:key="account.id"
|
||||||
|
:value="account.id"
|
||||||
|
>
|
||||||
|
{{ account.icon }} {{ account.name }}
|
||||||
|
</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日期 -->
|
||||||
|
<Row :gutter="16">
|
||||||
|
<Col :span="10">
|
||||||
|
<Form.Item label="日期快捷选择">
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<Button
|
||||||
|
:type="selectedDateType === 'today' ? 'primary' : 'default'"
|
||||||
|
:style="{ backgroundColor: selectedDateType === 'today' ? getDateTypeColor('today') : undefined, borderColor: selectedDateType === 'today' ? getDateTypeColor('today') : undefined }"
|
||||||
|
@click="setDate('today')"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
今天
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:type="selectedDateType === 'yesterday' ? 'primary' : 'default'"
|
||||||
|
:style="{ backgroundColor: selectedDateType === 'yesterday' ? getDateTypeColor('yesterday') : undefined, borderColor: selectedDateType === 'yesterday' ? getDateTypeColor('yesterday') : undefined }"
|
||||||
|
@click="setDate('yesterday')"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
昨天
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:type="selectedDateType === 'week' ? 'primary' : 'default'"
|
||||||
|
:style="{ backgroundColor: selectedDateType === 'week' ? getDateTypeColor('week') : undefined, borderColor: selectedDateType === 'week' ? getDateTypeColor('week') : undefined }"
|
||||||
|
@click="setDate('week')"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
本周
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:type="selectedDateType === 'month' ? 'primary' : 'default'"
|
||||||
|
:style="{ backgroundColor: selectedDateType === 'month' ? getDateTypeColor('month') : undefined, borderColor: selectedDateType === 'month' ? getDateTypeColor('month') : undefined }"
|
||||||
|
@click="setDate('month')"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
本月
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col :span="14">
|
||||||
|
<Form.Item
|
||||||
|
label="选择日期"
|
||||||
|
name="date"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="date-picker-wrapper"
|
||||||
|
:style="{
|
||||||
|
border: `2px solid ${getDateTypeColor(selectedDateType)}`,
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '4px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<DatePicker
|
||||||
|
v-model:value="formState.date"
|
||||||
|
placeholder="请选择日期"
|
||||||
|
style="width: 100%"
|
||||||
|
size="large"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 分类、货币和账户按钮组允许换行 */
|
||||||
|
:deep(.category-radio-group),
|
||||||
|
:deep(.currency-radio-group),
|
||||||
|
:deep(.account-radio-group) {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.category-radio-group .ant-radio-button-wrapper),
|
||||||
|
:deep(.currency-radio-group .ant-radio-button-wrapper),
|
||||||
|
:deep(.account-radio-group .ant-radio-button-wrapper) {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.category-radio-group .ant-radio-button-wrapper:not(:first-child)::before),
|
||||||
|
:deep(.currency-radio-group .ant-radio-button-wrapper:not(:first-child)::before),
|
||||||
|
:deep(.account-radio-group .ant-radio-button-wrapper:not(:first-child)::before) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<Card v-for="account in accounts" :key="account.id" class="hover:shadow-lg transition-shadow">
|
<Card v-for="account in accounts" :key="account.id" class="hover:shadow-lg transition-shadow">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span class="text-xl">{{ account.emoji }}</span>
|
<span class="text-xl">{{ account.icon }}</span>
|
||||||
<span>{{ account.name }}</span>
|
<span>{{ account.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,11 +70,10 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-2xl font-bold" :class="account.balance >= 0 ? 'text-green-600' : 'text-red-600'">
|
<p class="text-2xl font-bold" :class="account.balance >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||||
{{ account.balance.toLocaleString() }} {{ account.currency || 'CNY' }}
|
{{ getCurrencySymbol(account.currency) }}{{ account.balance.toLocaleString() }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500">{{ account.type }}</p>
|
<p class="text-sm text-gray-500">{{ getAccountTypeText(account.type) }}</p>
|
||||||
<p v-if="account.bank" class="text-xs text-gray-400">{{ account.bank }}</p>
|
<p class="text-xs text-blue-500 mt-1">{{ account.currency }}</p>
|
||||||
<p v-if="account.currency && account.currency !== 'CNY'" class="text-xs text-blue-500">{{ account.currency }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
@@ -85,10 +84,10 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 添加账户模态框 -->
|
<!-- 添加/编辑账户模态框 -->
|
||||||
<Modal
|
<Modal
|
||||||
v-model:open="showAddModal"
|
v-model:open="showAddModal"
|
||||||
title="➕ 添加新账户"
|
:title="isEditing ? '✏️ 编辑账户' : '➕ 添加新账户'"
|
||||||
@ok="submitAccount"
|
@ok="submitAccount"
|
||||||
@cancel="cancelAdd"
|
@cancel="cancelAdd"
|
||||||
width="500px"
|
width="500px"
|
||||||
@@ -220,21 +219,140 @@
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 转账模态框 -->
|
||||||
|
<Modal
|
||||||
|
v-model:open="showTransferModal"
|
||||||
|
title="💸 转账"
|
||||||
|
@ok="submitTransfer"
|
||||||
|
width="500px"
|
||||||
|
>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="转出账户">
|
||||||
|
<Select v-model:value="transferForm.fromAccount" disabled>
|
||||||
|
<Select.Option
|
||||||
|
v-for="account in accounts"
|
||||||
|
:key="account.id"
|
||||||
|
:value="account.id"
|
||||||
|
>
|
||||||
|
{{ account.icon }} {{ account.name }} ({{ getCurrencySymbol(account.currency) }}{{ account.balance.toLocaleString() }})
|
||||||
|
</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="转入账户" required>
|
||||||
|
<Select v-model:value="transferForm.toAccount" placeholder="选择转入账户">
|
||||||
|
<Select.Option
|
||||||
|
v-for="account in accounts.filter(a => a.id !== transferForm.fromAccount)"
|
||||||
|
:key="account.id"
|
||||||
|
:value="account.id"
|
||||||
|
>
|
||||||
|
{{ account.icon }} {{ account.name }} ({{ getCurrencySymbol(account.currency) }}{{ account.balance.toLocaleString() }})
|
||||||
|
</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="转账金额" required>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="transferForm.amount"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="请输入转账金额"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="备注">
|
||||||
|
<Input.TextArea
|
||||||
|
v-model:value="transferForm.description"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="转账备注(可选)"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 账户明细模态框 -->
|
||||||
|
<Modal
|
||||||
|
v-model:open="showDetailsModal"
|
||||||
|
:title="`📊 ${currentAccount?.name || ''} - 交易明细`"
|
||||||
|
width="900px"
|
||||||
|
:footer="null"
|
||||||
|
>
|
||||||
|
<div v-if="accountTransactions.length === 0" class="text-center py-12">
|
||||||
|
<div class="text-8xl mb-6">📊</div>
|
||||||
|
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无交易记录</h3>
|
||||||
|
<p class="text-gray-500">该账户还没有任何交易记录</p>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
v-else
|
||||||
|
:columns="[
|
||||||
|
{ title: '日期', dataIndex: 'transactionDate', key: 'transactionDate', width: 120 },
|
||||||
|
{ title: '类型', dataIndex: 'type', key: 'type', width: 80 },
|
||||||
|
{ title: '描述', dataIndex: 'description', key: 'description' },
|
||||||
|
{ title: '分类', dataIndex: 'categoryId', key: 'categoryId', width: 120 },
|
||||||
|
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 150 }
|
||||||
|
]"
|
||||||
|
:dataSource="accountTransactions"
|
||||||
|
:pagination="{ pageSize: 10 }"
|
||||||
|
:rowKey="record => record.id"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.dataIndex === 'type'">
|
||||||
|
<Tag :color="record.type === 'income' ? 'green' : 'red'">
|
||||||
|
{{ record.type === 'income' ? '📈 收入' : '📉 支出' }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.dataIndex === 'amount'">
|
||||||
|
<span :class="record.type === 'income' ? 'text-green-600 font-bold' : 'text-red-600 font-bold'">
|
||||||
|
{{ record.type === 'income' ? '+' : '-' }}{{ getCurrencySymbol(record.currency) }}{{ Math.abs(record.amount).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.dataIndex === 'categoryId'">
|
||||||
|
<Tag>{{ getCategoryName(record.categoryId) }}</Tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import {
|
import {
|
||||||
Card, Button, Modal, Form, Input, Select, Row, Col,
|
Card, Button, Modal, Form, Input, Select, Row, Col,
|
||||||
InputNumber, notification, Dropdown, Menu
|
InputNumber, notification, Dropdown, Menu, Table, Tag, Space
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { useFinanceStore } from '#/store/finance';
|
||||||
|
|
||||||
defineOptions({ name: 'AccountManagement' });
|
defineOptions({ name: 'AccountManagement' });
|
||||||
|
|
||||||
const accounts = ref<any[]>([]);
|
const financeStore = useFinanceStore();
|
||||||
|
|
||||||
|
// 使用 financeStore 的账户数据
|
||||||
|
const accounts = computed(() => financeStore.accounts);
|
||||||
|
|
||||||
|
// 初始化时加载数据
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
financeStore.fetchAccounts(),
|
||||||
|
financeStore.fetchTransactions(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
const showAddModal = ref(false);
|
const showAddModal = ref(false);
|
||||||
|
const showTransferModal = ref(false);
|
||||||
|
const showDetailsModal = ref(false);
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
|
const currentAccount = ref<any>(null);
|
||||||
|
const accountTransactions = computed(() => {
|
||||||
|
if (!currentAccount.value) return [];
|
||||||
|
return financeStore.transactions.filter(t =>
|
||||||
|
t.accountId === currentAccount.value.id && !t.isDeleted
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const totalAssets = computed(() => {
|
const totalAssets = computed(() => {
|
||||||
@@ -276,6 +394,15 @@ const accountForm = ref({
|
|||||||
color: '#1890ff'
|
color: '#1890ff'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 转账表单数据
|
||||||
|
const transferForm = ref({
|
||||||
|
fromAccount: '',
|
||||||
|
toAccount: '',
|
||||||
|
amount: null,
|
||||||
|
description: '',
|
||||||
|
date: null
|
||||||
|
});
|
||||||
|
|
||||||
// 账户颜色选项
|
// 账户颜色选项
|
||||||
const accountColors = ref([
|
const accountColors = ref([
|
||||||
'#1890ff', '#52c41a', '#fa541c', '#722ed1', '#eb2f96', '#13c2c2',
|
'#1890ff', '#52c41a', '#fa541c', '#722ed1', '#eb2f96', '#13c2c2',
|
||||||
@@ -320,39 +447,65 @@ const submitAccount = async () => {
|
|||||||
? accountForm.value.customBankName
|
? accountForm.value.customBankName
|
||||||
: accountForm.value.bank;
|
: accountForm.value.bank;
|
||||||
|
|
||||||
// 创建新账户
|
if (isEditing.value && editingAccount.value) {
|
||||||
const newAccount = {
|
// 编辑现有账户
|
||||||
id: Date.now().toString(),
|
const index = accounts.value.findIndex(a => a.id === editingAccount.value.id);
|
||||||
name: accountForm.value.name,
|
if (index !== -1) {
|
||||||
type: finalType,
|
accounts.value[index] = {
|
||||||
balance: accountForm.value.balance || 0,
|
...accounts.value[index],
|
||||||
currency: finalCurrency,
|
name: accountForm.value.name,
|
||||||
bank: finalBank,
|
type: finalType,
|
||||||
description: accountForm.value.description,
|
balance: accountForm.value.balance || 0,
|
||||||
color: accountForm.value.color,
|
currency: finalCurrency,
|
||||||
emoji: getAccountEmoji(accountForm.value.type),
|
bank: finalBank,
|
||||||
createdAt: new Date().toISOString(),
|
description: accountForm.value.description,
|
||||||
status: 'active'
|
color: accountForm.value.color,
|
||||||
};
|
icon: getAccountEmoji(accountForm.value.type),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
// 添加到账户列表
|
notification.success({
|
||||||
accounts.value.push(newAccount);
|
message: '账户更新成功',
|
||||||
|
description: `账户 "${accountForm.value.name}" 已更新`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 创建新账户
|
||||||
|
const newAccount = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: accountForm.value.name,
|
||||||
|
type: finalType,
|
||||||
|
balance: accountForm.value.balance || 0,
|
||||||
|
currency: finalCurrency,
|
||||||
|
bank: finalBank,
|
||||||
|
description: accountForm.value.description,
|
||||||
|
color: accountForm.value.color,
|
||||||
|
icon: getAccountEmoji(accountForm.value.type),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
status: 'active'
|
||||||
|
};
|
||||||
|
|
||||||
notification.success({
|
// 添加到账户列表
|
||||||
message: '账户添加成功',
|
accounts.value.push(newAccount);
|
||||||
description: `账户 "${newAccount.name}" 已成功创建`
|
|
||||||
});
|
notification.success({
|
||||||
|
message: '账户添加成功',
|
||||||
|
description: `账户 "${newAccount.name}" 已成功创建`
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('新增账户:', newAccount);
|
||||||
|
}
|
||||||
|
|
||||||
// 关闭模态框
|
// 关闭模态框
|
||||||
showAddModal.value = false;
|
showAddModal.value = false;
|
||||||
|
isEditing.value = false;
|
||||||
|
editingAccount.value = null;
|
||||||
resetForm();
|
resetForm();
|
||||||
|
|
||||||
console.log('新增账户:', newAccount);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('表单验证失败:', error);
|
console.error('表单验证失败:', error);
|
||||||
notification.error({
|
notification.error({
|
||||||
message: '添加失败',
|
message: isEditing.value ? '更新失败' : '添加失败',
|
||||||
description: '请检查表单信息是否正确'
|
description: '请检查表单信息是否正确'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -360,6 +513,8 @@ const submitAccount = async () => {
|
|||||||
|
|
||||||
const cancelAdd = () => {
|
const cancelAdd = () => {
|
||||||
showAddModal.value = false;
|
showAddModal.value = false;
|
||||||
|
isEditing.value = false;
|
||||||
|
editingAccount.value = null;
|
||||||
resetForm();
|
resetForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -401,12 +556,32 @@ const resetForm = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCurrencySymbol = (currency: string) => {
|
||||||
|
const symbolMap: Record<string, string> = {
|
||||||
|
'CNY': '¥',
|
||||||
|
'THB': '฿',
|
||||||
|
'USD': '$',
|
||||||
|
'EUR': '€',
|
||||||
|
'JPY': '¥',
|
||||||
|
'GBP': '£',
|
||||||
|
'HKD': 'HK$',
|
||||||
|
'KRW': '₩'
|
||||||
|
};
|
||||||
|
return symbolMap[currency] || currency + ' ';
|
||||||
|
};
|
||||||
|
|
||||||
const getAccountTypeText = (type: string) => {
|
const getAccountTypeText = (type: string) => {
|
||||||
const typeMap = {
|
const typeMap: Record<string, string> = {
|
||||||
|
'cash': '现金',
|
||||||
|
'bank': '银行卡',
|
||||||
|
'alipay': '支付宝',
|
||||||
|
'wechat': '微信',
|
||||||
|
'virtual_wallet': '虚拟钱包',
|
||||||
|
'investment': '投资账户',
|
||||||
|
'credit_card': '信用卡',
|
||||||
'savings': '储蓄账户',
|
'savings': '储蓄账户',
|
||||||
'checking': '支票账户',
|
'checking': '支票账户',
|
||||||
'credit': '信用卡',
|
'credit': '信用卡',
|
||||||
'investment': '投资账户',
|
|
||||||
'ewallet': '电子钱包'
|
'ewallet': '电子钱包'
|
||||||
};
|
};
|
||||||
return typeMap[type] || type;
|
return typeMap[type] || type;
|
||||||
@@ -423,12 +598,34 @@ const getAccountEmoji = (type: string) => {
|
|||||||
return emojiMap[type] || '🏦';
|
return emojiMap[type] || '🏦';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEditing = ref(false);
|
||||||
|
const editingAccount = ref<any>(null);
|
||||||
|
|
||||||
const editAccount = (account: any) => {
|
const editAccount = (account: any) => {
|
||||||
console.log('编辑账户:', account);
|
console.log('编辑账户:', account);
|
||||||
notification.info({
|
isEditing.value = true;
|
||||||
message: '编辑功能',
|
editingAccount.value = account;
|
||||||
description: '账户编辑功能'
|
|
||||||
});
|
// 填充表单
|
||||||
|
accountForm.value = {
|
||||||
|
name: account.name,
|
||||||
|
type: account.type === '储蓄账户' ? 'savings' :
|
||||||
|
account.type === '支票账户' ? 'checking' :
|
||||||
|
account.type === '信用卡' ? 'credit' :
|
||||||
|
account.type === '投资账户' ? 'investment' :
|
||||||
|
account.type === '电子钱包' ? 'ewallet' : 'CUSTOM',
|
||||||
|
customTypeName: ['储蓄账户', '支票账户', '信用卡', '投资账户', '电子钱包'].includes(account.type) ? '' : account.type,
|
||||||
|
balance: account.balance,
|
||||||
|
currency: ['CNY', 'USD', 'EUR', 'JPY', 'GBP', 'HKD', 'KRW'].includes(account.currency) ? account.currency : 'CUSTOM',
|
||||||
|
customCurrencyCode: ['CNY', 'USD', 'EUR', 'JPY', 'GBP', 'HKD', 'KRW'].includes(account.currency) ? '' : account.currency,
|
||||||
|
customCurrencyName: '',
|
||||||
|
bank: account.bank || '',
|
||||||
|
customBankName: '',
|
||||||
|
description: account.description || '',
|
||||||
|
color: account.color || '#1890ff'
|
||||||
|
};
|
||||||
|
|
||||||
|
showAddModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteAccount = (account: any) => {
|
const deleteAccount = (account: any) => {
|
||||||
@@ -445,18 +642,98 @@ const deleteAccount = (account: any) => {
|
|||||||
|
|
||||||
const transfer = (account: any) => {
|
const transfer = (account: any) => {
|
||||||
console.log('转账功能:', account);
|
console.log('转账功能:', account);
|
||||||
notification.info({
|
currentAccount.value = account;
|
||||||
message: '转账功能',
|
transferForm.value = {
|
||||||
description: `从 ${account.name} 转账功能`
|
fromAccount: account.id,
|
||||||
});
|
toAccount: '',
|
||||||
|
amount: null,
|
||||||
|
description: '',
|
||||||
|
date: new Date()
|
||||||
|
};
|
||||||
|
showTransferModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitTransfer = async () => {
|
||||||
|
try {
|
||||||
|
if (!transferForm.value.toAccount || !transferForm.value.amount) {
|
||||||
|
notification.error({
|
||||||
|
message: '转账失败',
|
||||||
|
description: '请填写完整的转账信息'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transferForm.value.fromAccount === transferForm.value.toAccount) {
|
||||||
|
notification.error({
|
||||||
|
message: '转账失败',
|
||||||
|
description: '转出和转入账户不能相同'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromAccount = financeStore.getAccountById(Number(transferForm.value.fromAccount));
|
||||||
|
const toAccount = financeStore.getAccountById(Number(transferForm.value.toAccount));
|
||||||
|
|
||||||
|
if (!fromAccount || !toAccount) {
|
||||||
|
notification.error({
|
||||||
|
message: '转账失败',
|
||||||
|
description: '账户不存在'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建转出交易(支出)
|
||||||
|
await financeStore.createTransaction({
|
||||||
|
type: 'expense',
|
||||||
|
amount: transferForm.value.amount,
|
||||||
|
currency: fromAccount.currency,
|
||||||
|
accountId: Number(transferForm.value.fromAccount),
|
||||||
|
transactionDate: new Date().toISOString().split('T')[0],
|
||||||
|
description: `转账至 ${toAccount.name}${transferForm.value.description ? ' - ' + transferForm.value.description : ''}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建转入交易(收入)
|
||||||
|
await financeStore.createTransaction({
|
||||||
|
type: 'income',
|
||||||
|
amount: transferForm.value.amount,
|
||||||
|
currency: toAccount.currency,
|
||||||
|
accountId: Number(transferForm.value.toAccount),
|
||||||
|
transactionDate: new Date().toISOString().split('T')[0],
|
||||||
|
description: `从 ${fromAccount.name} 转入${transferForm.value.description ? ' - ' + transferForm.value.description : ''}`
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.success({
|
||||||
|
message: '转账成功',
|
||||||
|
description: `已从 ${fromAccount.name} 转账 ${getCurrencySymbol(fromAccount.currency)}${transferForm.value.amount} 到 ${toAccount.name}`
|
||||||
|
});
|
||||||
|
|
||||||
|
showTransferModal.value = false;
|
||||||
|
transferForm.value = {
|
||||||
|
fromAccount: '',
|
||||||
|
toAccount: '',
|
||||||
|
amount: null,
|
||||||
|
description: '',
|
||||||
|
date: null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('转账失败:', error);
|
||||||
|
notification.error({
|
||||||
|
message: '转账失败',
|
||||||
|
description: '转账时出错,请稍后重试'
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewDetails = (account: any) => {
|
const viewDetails = (account: any) => {
|
||||||
console.log('查看明细:', account);
|
console.log('查看明细:', account);
|
||||||
notification.info({
|
currentAccount.value = account;
|
||||||
message: '账户明细',
|
showDetailsModal.value = true;
|
||||||
description: `查看 ${account.name} 交易明细`
|
};
|
||||||
});
|
|
||||||
|
const getCategoryName = (categoryId: number | null) => {
|
||||||
|
if (!categoryId) return '未分类';
|
||||||
|
const category = financeStore.getCategoryById(categoryId);
|
||||||
|
return category ? `${category.icon} ${category.name}` : '未知分类';
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -265,15 +265,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import {
|
import {
|
||||||
Card, Progress, Button, Modal, Form, Input, Select, Row, Col,
|
Card, Progress, Button, Modal, Form, Input, Select, Row, Col,
|
||||||
InputNumber, Slider, Switch, Tag, notification, Dropdown, Menu
|
InputNumber, Slider, Switch, Tag, notification, Dropdown, Menu
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useFinanceStore } from '#/store/finance';
|
||||||
|
|
||||||
defineOptions({ name: 'BudgetManagement' });
|
defineOptions({ name: 'BudgetManagement' });
|
||||||
|
|
||||||
const budgets = ref([]);
|
const financeStore = useFinanceStore();
|
||||||
|
const budgets = computed(() => financeStore.budgets.filter(b => !b.isDeleted));
|
||||||
const showAddModal = ref(false);
|
const showAddModal = ref(false);
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
|
|
||||||
@@ -431,13 +434,12 @@ const submitBudget = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建新预算
|
// 创建新预算
|
||||||
const newBudget = {
|
await financeStore.createBudget({
|
||||||
id: Date.now().toString(),
|
|
||||||
category: finalCategory,
|
category: finalCategory,
|
||||||
emoji: finalEmoji,
|
emoji: finalEmoji,
|
||||||
limit: budgetForm.value.limit,
|
limit: budgetForm.value.limit,
|
||||||
currency: finalCurrency,
|
currency: finalCurrency,
|
||||||
spent: 0, // 初始已用金额为0
|
spent: 0,
|
||||||
remaining: budgetForm.value.limit,
|
remaining: budgetForm.value.limit,
|
||||||
percentage: 0,
|
percentage: 0,
|
||||||
period: budgetForm.value.period,
|
period: budgetForm.value.period,
|
||||||
@@ -447,23 +449,17 @@ const submitBudget = async () => {
|
|||||||
overspendAlert: budgetForm.value.overspendAlert,
|
overspendAlert: budgetForm.value.overspendAlert,
|
||||||
dailyReminder: budgetForm.value.dailyReminder,
|
dailyReminder: budgetForm.value.dailyReminder,
|
||||||
monthlyTrend: 0,
|
monthlyTrend: 0,
|
||||||
createdAt: new Date().toISOString()
|
});
|
||||||
};
|
|
||||||
|
|
||||||
// 添加到预算列表
|
|
||||||
budgets.value.push(newBudget);
|
|
||||||
|
|
||||||
notification.success({
|
notification.success({
|
||||||
message: '预算设置成功',
|
message: '预算设置成功',
|
||||||
description: `${newBudget.category} 预算已成功创建`
|
description: `${finalCategory} 预算已成功创建`
|
||||||
});
|
});
|
||||||
|
|
||||||
// 关闭模态框
|
// 关闭模态框
|
||||||
showAddModal.value = false;
|
showAddModal.value = false;
|
||||||
resetForm();
|
resetForm();
|
||||||
|
|
||||||
console.log('新增预算:', newBudget);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('表单验证失败:', error);
|
console.error('表单验证失败:', error);
|
||||||
notification.error({
|
notification.error({
|
||||||
@@ -537,17 +533,18 @@ const viewHistory = (budget: any) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteBudget = (budget: any) => {
|
const deleteBudget = async (budget: any) => {
|
||||||
console.log('删除预算:', budget);
|
console.log('删除预算:', budget);
|
||||||
const index = budgets.value.findIndex(b => b.id === budget.id);
|
await financeStore.deleteBudget(budget.id);
|
||||||
if (index !== -1) {
|
notification.success({
|
||||||
budgets.value.splice(index, 1);
|
message: '预算已删除',
|
||||||
notification.success({
|
description: `${budget.category} 预算已删除`
|
||||||
message: '预算已删除',
|
});
|
||||||
description: `${budget.category} 预算已删除`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await financeStore.fetchBudgets();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -16,34 +16,24 @@
|
|||||||
<div v-for="category in categories" :key="category.id" class="p-4 border rounded-lg hover:shadow-md transition-shadow">
|
<div 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 justify-between">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<span class="text-xl" :style="{ color: category.color }">{{ category.emoji }}</span>
|
<span class="text-2xl">{{ category.icon }}</span>
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium text-lg">{{ category.name }}</span>
|
<span class="font-medium text-lg">{{ category.name }}</span>
|
||||||
<div class="flex items-center space-x-2 mt-1">
|
<div class="flex items-center space-x-2 mt-1">
|
||||||
<Tag :color="category.type === 'income' ? 'green' : 'red'" size="small">
|
<Tag :color="category.type === 'income' ? 'green' : 'red'" size="small">
|
||||||
{{ category.type === 'income' ? '📈 收入' : '📉 支出' }}
|
{{ category.type === 'income' ? '📈 收入' : '📉 支出' }}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Tag size="small">{{ category.count }}笔交易</Tag>
|
<Tag v-if="category.isSystem" color="blue" size="small">系统分类</Tag>
|
||||||
<Tag v-if="category.budget > 0" color="blue" size="small">
|
|
||||||
预算{{ category.budget.toLocaleString() }} {{ category.budgetCurrency || 'CNY' }}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="text-lg font-semibold" :class="category.type === 'income' ? 'text-green-600' : 'text-red-600'">
|
<div class="space-x-2">
|
||||||
{{ category.amount.toLocaleString('zh-CN', { style: 'currency', currency: 'CNY' }) }}
|
|
||||||
</p>
|
|
||||||
<div class="mt-2 space-x-2">
|
|
||||||
<Button type="link" size="small" @click="editCategory(category)">✏️ 编辑</Button>
|
<Button type="link" size="small" @click="editCategory(category)">✏️ 编辑</Button>
|
||||||
<Button type="link" size="small" @click="setBudget(category)">🎯 预算</Button>
|
<Button type="link" size="small" danger @click="deleteCategory(category)" :disabled="category.isSystem">🗑️ 删除</Button>
|
||||||
<Button type="link" size="small" danger @click="deleteCategory(category)">🗑️ 删除</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="category.description" class="mt-2 text-sm text-gray-500">
|
|
||||||
{{ category.description }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -82,10 +72,11 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-for="category in incomeCategories" :key="category.id"
|
<div v-for="category in incomeCategories" :key="category.id"
|
||||||
class="flex items-center justify-between p-2 bg-green-50 rounded">
|
class="flex items-center justify-between p-2 bg-green-50 rounded">
|
||||||
<span>{{ category.emoji }} {{ category.name }}</span>
|
<div class="flex items-center space-x-2">
|
||||||
<span class="text-green-600 font-medium">
|
<span>{{ category.icon }}</span>
|
||||||
{{ category.amount.toLocaleString('zh-CN', { style: 'currency', currency: 'CNY' }) }}
|
<span>{{ category.name }}</span>
|
||||||
</span>
|
</div>
|
||||||
|
<Tag :color="category.color" size="small">{{ category.isSystem ? '系统' : '自定义' }}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="incomeCategories.length === 0" class="text-center text-gray-500 py-2">
|
<div v-if="incomeCategories.length === 0" class="text-center text-gray-500 py-2">
|
||||||
暂无收入分类
|
暂无收入分类
|
||||||
@@ -96,10 +87,11 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-for="category in expenseCategories" :key="category.id"
|
<div v-for="category in expenseCategories" :key="category.id"
|
||||||
class="flex items-center justify-between p-2 bg-red-50 rounded">
|
class="flex items-center justify-between p-2 bg-red-50 rounded">
|
||||||
<span>{{ category.emoji }} {{ category.name }}</span>
|
<div class="flex items-center space-x-2">
|
||||||
<span class="text-red-600 font-medium">
|
<span>{{ category.icon }}</span>
|
||||||
{{ category.amount.toLocaleString('zh-CN', { style: 'currency', currency: 'CNY' }) }}
|
<span>{{ category.name }}</span>
|
||||||
</span>
|
</div>
|
||||||
|
<Tag :color="category.color" size="small">{{ category.isSystem ? '系统' : '自定义' }}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="expenseCategories.length === 0" class="text-center text-gray-500 py-2">
|
<div v-if="expenseCategories.length === 0" class="text-center text-gray-500 py-2">
|
||||||
暂无支出分类
|
暂无支出分类
|
||||||
@@ -110,6 +102,69 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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
|
<Modal
|
||||||
v-model:open="showAddModal"
|
v-model:open="showAddModal"
|
||||||
@@ -236,17 +291,32 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import {
|
import {
|
||||||
Card, Tag, Button, Modal, Form, Input, Select, Row, Col,
|
Card, Tag, Button, Modal, Form, Input, Select, Row, Col,
|
||||||
InputNumber, notification
|
InputNumber, notification
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useFinanceStore } from '#/store/finance';
|
||||||
|
|
||||||
defineOptions({ name: 'CategoryManagement' });
|
defineOptions({ name: 'CategoryManagement' });
|
||||||
|
|
||||||
const categories = ref([]);
|
const financeStore = useFinanceStore();
|
||||||
|
|
||||||
|
// 使用 financeStore 的分类数据
|
||||||
|
const categories = computed(() => {
|
||||||
|
return [...financeStore.incomeCategories, ...financeStore.expenseCategories];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化时加载数据
|
||||||
|
onMounted(async () => {
|
||||||
|
await financeStore.fetchCategories();
|
||||||
|
});
|
||||||
const showAddModal = ref(false);
|
const showAddModal = ref(false);
|
||||||
|
const showEditModal = ref(false);
|
||||||
|
const editingCategory = ref<any>(null);
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
|
const editFormRef = ref();
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const categoryForm = ref({
|
const categoryForm = ref({
|
||||||
@@ -262,6 +332,14 @@ const categoryForm = ref({
|
|||||||
color: '#1890ff'
|
color: '#1890ff'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 编辑表单数据
|
||||||
|
const editForm = ref({
|
||||||
|
name: '',
|
||||||
|
icon: '🏷️',
|
||||||
|
customIcon: '',
|
||||||
|
color: '#1890ff'
|
||||||
|
});
|
||||||
|
|
||||||
// 分类颜色选项
|
// 分类颜色选项
|
||||||
const categoryColors = ref([
|
const categoryColors = ref([
|
||||||
'#1890ff', '#52c41a', '#fa541c', '#722ed1', '#eb2f96', '#13c2c2',
|
'#1890ff', '#52c41a', '#fa541c', '#722ed1', '#eb2f96', '#13c2c2',
|
||||||
@@ -288,7 +366,7 @@ const categoryStats = computed(() => {
|
|||||||
total: categories.value.length,
|
total: categories.value.length,
|
||||||
income: incomeCategories.length,
|
income: incomeCategories.length,
|
||||||
expense: expenseCategories.length,
|
expense: expenseCategories.length,
|
||||||
budgetTotal: categories.value.reduce((sum, c) => sum + (c.budget || 0), 0)
|
budgetTotal: 0 // 预算功能待实现
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -312,47 +390,30 @@ const submitCategory = async () => {
|
|||||||
// 表单验证
|
// 表单验证
|
||||||
await formRef.value.validate();
|
await formRef.value.validate();
|
||||||
|
|
||||||
// 处理自定义字段
|
// 处理自定义图标
|
||||||
const finalIcon = categoryForm.value.icon === 'CUSTOM'
|
const finalIcon = categoryForm.value.icon === 'CUSTOM'
|
||||||
? categoryForm.value.customIcon
|
? categoryForm.value.customIcon
|
||||||
: categoryForm.value.icon;
|
: categoryForm.value.icon;
|
||||||
|
|
||||||
const finalBudgetCurrency = categoryForm.value.budgetCurrency === 'CUSTOM'
|
// 调用 store 创建分类
|
||||||
? `${categoryForm.value.customCurrencyCode} (${categoryForm.value.customCurrencyName})`
|
await financeStore.createCategory({
|
||||||
: categoryForm.value.budgetCurrency;
|
|
||||||
|
|
||||||
// 创建新分类
|
|
||||||
const newCategory = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
name: categoryForm.value.name,
|
name: categoryForm.value.name,
|
||||||
type: categoryForm.value.type,
|
type: categoryForm.value.type,
|
||||||
icon: finalIcon,
|
icon: finalIcon,
|
||||||
budget: categoryForm.value.budget || 0,
|
|
||||||
budgetCurrency: finalBudgetCurrency,
|
|
||||||
description: categoryForm.value.description,
|
|
||||||
color: categoryForm.value.color,
|
color: categoryForm.value.color,
|
||||||
count: 0, // 交易数量
|
});
|
||||||
amount: 0, // 总金额
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
emoji: finalIcon
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加到分类列表
|
|
||||||
categories.value.push(newCategory);
|
|
||||||
|
|
||||||
notification.success({
|
notification.success({
|
||||||
message: '分类添加成功',
|
message: '分类添加成功',
|
||||||
description: `分类 "${newCategory.name}" 已成功创建`
|
description: `分类 "${categoryForm.value.name}" 已成功创建`
|
||||||
});
|
});
|
||||||
|
|
||||||
// 关闭模态框
|
// 关闭模态框
|
||||||
showAddModal.value = false;
|
showAddModal.value = false;
|
||||||
resetForm();
|
resetForm();
|
||||||
|
|
||||||
console.log('新增分类:', newCategory);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('表单验证失败:', error);
|
console.error('创建分类失败:', error);
|
||||||
notification.error({
|
notification.error({
|
||||||
message: '添加失败',
|
message: '添加失败',
|
||||||
description: '请检查表单信息是否正确'
|
description: '请检查表单信息是否正确'
|
||||||
@@ -396,23 +457,81 @@ const handleBudgetCurrencyChange = (currency: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editCategory = (category: any) => {
|
const editCategory = (category: any) => {
|
||||||
console.log('编辑分类:', category);
|
editingCategory.value = category;
|
||||||
notification.info({
|
editForm.value = {
|
||||||
message: '编辑功能',
|
name: category.name,
|
||||||
description: `编辑分类 "${category.name}"`
|
icon: category.icon,
|
||||||
});
|
customIcon: '',
|
||||||
|
color: category.color || '#1890ff',
|
||||||
|
};
|
||||||
|
showEditModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitEditCategory = async () => {
|
||||||
|
try {
|
||||||
|
await editFormRef.value?.validate();
|
||||||
|
|
||||||
|
// 处理自定义图标
|
||||||
|
const finalIcon = editForm.value.icon === 'CUSTOM'
|
||||||
|
? editForm.value.customIcon
|
||||||
|
: editForm.value.icon;
|
||||||
|
|
||||||
|
// 调用 store 更新分类
|
||||||
|
await financeStore.updateCategory(editingCategory.value.id, {
|
||||||
|
name: editForm.value.name,
|
||||||
|
icon: finalIcon,
|
||||||
|
color: editForm.value.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.success({
|
||||||
|
message: '分类更新成功',
|
||||||
|
description: `分类 "${editForm.value.name}" 已更新`
|
||||||
|
});
|
||||||
|
|
||||||
|
showEditModal.value = false;
|
||||||
|
editingCategory.value = null;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新分类失败:', error);
|
||||||
|
notification.error({
|
||||||
|
message: '更新失败',
|
||||||
|
description: '请检查表单信息是否正确'
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCategory = (category: any) => {
|
const deleteCategory = (category: any) => {
|
||||||
console.log('删除分类:', category);
|
// 系统分类不允许删除
|
||||||
const index = categories.value.findIndex(c => c.id === category.id);
|
if (category.isSystem) {
|
||||||
if (index !== -1) {
|
notification.warning({
|
||||||
categories.value.splice(index, 1);
|
message: '无法删除',
|
||||||
notification.success({
|
description: '系统分类不允许删除'
|
||||||
message: '分类已删除',
|
|
||||||
description: `分类 "${category.name}" 已删除`
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除分类 "${category.name}" 吗?此操作不可恢复。`,
|
||||||
|
okText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await financeStore.deleteCategory(category.id);
|
||||||
|
notification.success({
|
||||||
|
message: '分类已删除',
|
||||||
|
description: `分类 "${category.name}" 已删除`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除分类失败:', error);
|
||||||
|
notification.error({
|
||||||
|
message: '删除失败',
|
||||||
|
description: '删除分类时出错,请稍后重试'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const setBudget = (category: any) => {
|
const setBudget = (category: any) => {
|
||||||
|
|||||||
@@ -1,35 +1,786 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="mb-6">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">📈 报表分析</h1>
|
<div>
|
||||||
<p class="text-gray-600">全面的财务数据分析与报表</p>
|
<h1 class="text-3xl font-bold text-gray-900 mb-2">📈 财务报表</h1>
|
||||||
|
<p class="text-gray-600">全面的财务数据分析与报表生成</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<Button @click="showImportModal = true">
|
||||||
|
📤 导入报表
|
||||||
|
</Button>
|
||||||
|
<Button @click="showExportModal = true" type="primary">
|
||||||
|
📥 导出报表
|
||||||
|
</Button>
|
||||||
|
<Button @click="printReport">
|
||||||
|
🖨️ 打印报表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<!-- 时间筛选 -->
|
||||||
<Card title="📊 现金流分析">
|
<Card class="mb-6">
|
||||||
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
<div class="flex items-center space-x-4">
|
||||||
<div class="text-center">
|
<span class="font-medium">报表周期:</span>
|
||||||
<div class="text-4xl mb-2">📈</div>
|
<Radio.Group v-model:value="period" button-style="solid">
|
||||||
<p class="text-gray-600">现金流趋势图</p>
|
<Radio.Button value="month">本月</Radio.Button>
|
||||||
|
<Radio.Button value="quarter">本季度</Radio.Button>
|
||||||
|
<Radio.Button value="year">本年</Radio.Button>
|
||||||
|
<Radio.Button value="all">全部</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
<RangePicker v-if="period === 'custom'" v-model:value="customRange" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 核心指标汇总 -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||||
|
<div class="text-3xl mb-2">💰</div>
|
||||||
|
<p class="text-sm text-gray-500">总收入</p>
|
||||||
|
<p class="text-2xl font-bold text-green-600">
|
||||||
|
¥{{ periodIncome.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||||
|
<div class="text-3xl mb-2">💸</div>
|
||||||
|
<p class="text-sm text-gray-500">总支出</p>
|
||||||
|
<p class="text-2xl font-bold text-red-600">
|
||||||
|
¥{{ periodExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||||
|
<div class="text-3xl mb-2">💎</div>
|
||||||
|
<p class="text-sm text-gray-500">净收入</p>
|
||||||
|
<p class="text-2xl font-bold" :class="periodNet >= 0 ? 'text-purple-600' : 'text-red-600'">
|
||||||
|
{{ periodNet >= 0 ? '+' : '' }}¥{{ periodNet.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||||
|
<div class="text-3xl mb-2">📊</div>
|
||||||
|
<p class="text-sm text-gray-500">交易笔数</p>
|
||||||
|
<p class="text-2xl font-bold text-blue-600">{{ periodTransactions.length }}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详细报表 -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
<!-- 收入分析 -->
|
||||||
|
<Card title="📈 收入分析">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-if="incomeByCategory.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-gray-500">暂无收入数据</p>
|
||||||
|
</div>
|
||||||
|
<div v-else v-for="item in incomeByCategory" :key="item.categoryId" class="p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<span class="font-medium">{{ item.categoryName }}</span>
|
||||||
|
<span class="text-sm font-bold text-green-600">
|
||||||
|
¥{{ item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="flex-1 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="bg-gradient-to-r from-green-400 to-green-600 h-2 rounded-full transition-all duration-500"
|
||||||
|
:style="{ width: item.percentage + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 w-12 text-right">{{ item.percentage }}%</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{ item.count }} 笔 · 平均 ¥{{ (item.amount / item.count).toFixed(2) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="🥧 支出分析">
|
<!-- 支出分析 -->
|
||||||
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
<Card title="📉 支出分析">
|
||||||
<div class="text-center">
|
<div class="space-y-3">
|
||||||
<div class="text-4xl mb-2">🍰</div>
|
<div v-if="expenseByCategory.length === 0" class="text-center py-8">
|
||||||
<p class="text-gray-600">支出分布图</p>
|
<p class="text-gray-500">暂无支出数据</p>
|
||||||
|
</div>
|
||||||
|
<div v-else v-for="item in expenseByCategory" :key="item.categoryId" class="p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<span class="font-medium">{{ item.categoryName }}</span>
|
||||||
|
<span class="text-sm font-bold text-red-600">
|
||||||
|
¥{{ item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="flex-1 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="bg-gradient-to-r from-red-400 to-red-600 h-2 rounded-full transition-all duration-500"
|
||||||
|
:style="{ width: item.percentage + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 w-12 text-right">{{ item.percentage }}%</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{ item.count }} 笔 · 平均 ¥{{ (item.amount / item.count).toFixed(2) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 交易明细表 -->
|
||||||
|
<Card title="📋 交易明细">
|
||||||
|
<Table
|
||||||
|
:columns="columns"
|
||||||
|
:dataSource="periodTransactions"
|
||||||
|
:pagination="{ pageSize: 20 }"
|
||||||
|
:rowKey="record => record.id"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.dataIndex === 'type'">
|
||||||
|
<Tag :color="record.type === 'income' ? 'green' : 'red'">
|
||||||
|
{{ record.type === 'income' ? '📈 收入' : '📉 支出' }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.dataIndex === 'amount'">
|
||||||
|
<span :class="record.type === 'income' ? 'text-green-600 font-bold' : 'text-red-600 font-bold'">
|
||||||
|
{{ record.type === 'income' ? '+' : '-' }}¥{{ Math.abs(record.amount).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.dataIndex === 'categoryId'">
|
||||||
|
<Tag>{{ getCategoryName(record.categoryId) }}</Tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.dataIndex === 'accountId'">
|
||||||
|
{{ getAccountName(record.accountId) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 导出报表模态框 -->
|
||||||
|
<Modal v-model:open="showExportModal" title="📥 导出财务报表" @ok="handleExportReport" width="600px">
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="导出格式" required>
|
||||||
|
<Radio.Group v-model:value="exportOptions.format" size="large">
|
||||||
|
<Radio.Button value="pdf">📄 PDF 格式</Radio.Button>
|
||||||
|
<Radio.Button value="excel">📊 Excel 格式</Radio.Button>
|
||||||
|
<Radio.Button value="csv">📋 CSV 格式</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="包含内容">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" v-model="exportOptions.includeSummary" class="mr-2" /> 核心指标汇总
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" v-model="exportOptions.includeIncome" class="mr-2" /> 收入分析
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" v-model="exportOptions.includeExpense" class="mr-2" /> 支出分析
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" v-model="exportOptions.includeTransactions" class="mr-2" /> 交易明细
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="报表标题">
|
||||||
|
<Input v-model:value="exportOptions.title" placeholder="财务报表" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- 导入报表模态框 -->
|
||||||
|
<Modal v-model:open="showImportModal" title="📤 导入财务报表数据" @ok="handleImportReport" width="700px">
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="上传文件" required>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv,.json"
|
||||||
|
@change="handleReportFileUpload"
|
||||||
|
class="block w-full text-sm text-gray-500
|
||||||
|
file:mr-4 file:py-2 file:px-4
|
||||||
|
file:rounded-full file:border-0
|
||||||
|
file:text-sm file:font-semibold
|
||||||
|
file:bg-blue-50 file:text-blue-700
|
||||||
|
hover:file:bg-blue-100"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">支持 Excel (.xlsx, .xls)、CSV 和 JSON 格式</p>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div v-if="importPreviewData.length > 0">
|
||||||
|
<Form.Item label="数据预览">
|
||||||
|
<div class="border rounded-lg overflow-auto max-h-60">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left">日期</th>
|
||||||
|
<th class="px-4 py-2 text-left">类型</th>
|
||||||
|
<th class="px-4 py-2 text-left">分类</th>
|
||||||
|
<th class="px-4 py-2 text-left">金额</th>
|
||||||
|
<th class="px-4 py-2 text-left">描述</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, idx) in importPreviewData.slice(0, 5)" :key="idx" class="border-t">
|
||||||
|
<td class="px-4 py-2">{{ row.date || row['日期'] }}</td>
|
||||||
|
<td class="px-4 py-2">{{ row.type || row['类型'] }}</td>
|
||||||
|
<td class="px-4 py-2">{{ row.category || row['分类'] }}</td>
|
||||||
|
<td class="px-4 py-2">{{ row.amount || row['金额'] }}</td>
|
||||||
|
<td class="px-4 py-2">{{ row.description || row['描述'] || row['项目名称'] }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">
|
||||||
|
预览前 5 条数据,共 {{ importPreviewData.length }} 条待导入
|
||||||
|
</p>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Card } from 'ant-design-vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { Card, Button, Radio, DatePicker, Table, Tag, notification, Modal, Form, Input } from 'ant-design-vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
import { useFinanceStore } from '#/store/finance';
|
||||||
|
|
||||||
defineOptions({ name: 'ReportsAnalytics' });
|
defineOptions({ name: 'ReportsAnalytics' });
|
||||||
|
|
||||||
|
const financeStore = useFinanceStore();
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
const period = ref('month');
|
||||||
|
const customRange = ref();
|
||||||
|
|
||||||
|
// 导出和导入状态
|
||||||
|
const showExportModal = ref(false);
|
||||||
|
const showImportModal = ref(false);
|
||||||
|
const importPreviewData = ref<any[]>([]);
|
||||||
|
|
||||||
|
const exportOptions = ref({
|
||||||
|
format: 'excel' as 'pdf' | 'excel' | 'csv',
|
||||||
|
includeSummary: true,
|
||||||
|
includeIncome: true,
|
||||||
|
includeExpense: true,
|
||||||
|
includeTransactions: true,
|
||||||
|
title: '财务报表'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取周期内的交易
|
||||||
|
const periodTransactions = computed(() => {
|
||||||
|
const now = dayjs();
|
||||||
|
let startDate: dayjs.Dayjs;
|
||||||
|
|
||||||
|
switch (period.value) {
|
||||||
|
case 'month':
|
||||||
|
startDate = now.startOf('month');
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
startDate = now.startOf('quarter');
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
startDate = now.startOf('year');
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
return financeStore.transactions.filter(t => !t.isDeleted);
|
||||||
|
default:
|
||||||
|
return financeStore.transactions.filter(t => !t.isDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return financeStore.transactions.filter(t =>
|
||||||
|
!t.isDeleted && dayjs(t.transactionDate).isAfter(startDate)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 周期收入
|
||||||
|
const periodIncome = computed(() => {
|
||||||
|
return periodTransactions.value
|
||||||
|
.filter(t => t.type === 'income')
|
||||||
|
.reduce((sum, t) => sum + t.amountInBase, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 周期支出
|
||||||
|
const periodExpense = computed(() => {
|
||||||
|
return periodTransactions.value
|
||||||
|
.filter(t => t.type === 'expense')
|
||||||
|
.reduce((sum, t) => sum + t.amountInBase, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 周期净收入
|
||||||
|
const periodNet = computed(() => periodIncome.value - periodExpense.value);
|
||||||
|
|
||||||
|
// 按分类统计收入
|
||||||
|
const incomeByCategory = computed(() => {
|
||||||
|
const incomeTransactions = periodTransactions.value.filter(t => t.type === 'income');
|
||||||
|
if (incomeTransactions.length === 0) return [];
|
||||||
|
|
||||||
|
const categoryMap = new Map();
|
||||||
|
incomeTransactions.forEach(t => {
|
||||||
|
const categoryId = t.categoryId || 0;
|
||||||
|
if (!categoryMap.has(categoryId)) {
|
||||||
|
categoryMap.set(categoryId, {
|
||||||
|
categoryId,
|
||||||
|
categoryName: financeStore.getCategoryById(categoryId)?.name || '未分类',
|
||||||
|
amount: 0,
|
||||||
|
count: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const category = categoryMap.get(categoryId);
|
||||||
|
category.amount += t.amountInBase;
|
||||||
|
category.count += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(categoryMap.values())
|
||||||
|
.map(item => ({
|
||||||
|
...item,
|
||||||
|
percentage: Math.round((item.amount / periodIncome.value) * 100)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.amount - a.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按分类统计支出
|
||||||
|
const expenseByCategory = computed(() => {
|
||||||
|
const expenseTransactions = periodTransactions.value.filter(t => t.type === 'expense');
|
||||||
|
if (expenseTransactions.length === 0) return [];
|
||||||
|
|
||||||
|
const categoryMap = new Map();
|
||||||
|
expenseTransactions.forEach(t => {
|
||||||
|
const categoryId = t.categoryId || 0;
|
||||||
|
if (!categoryMap.has(categoryId)) {
|
||||||
|
categoryMap.set(categoryId, {
|
||||||
|
categoryId,
|
||||||
|
categoryName: financeStore.getCategoryById(categoryId)?.name || '未分类',
|
||||||
|
amount: 0,
|
||||||
|
count: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const category = categoryMap.get(categoryId);
|
||||||
|
category.amount += t.amountInBase;
|
||||||
|
category.count += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(categoryMap.values())
|
||||||
|
.map(item => ({
|
||||||
|
...item,
|
||||||
|
percentage: Math.round((item.amount / periodExpense.value) * 100)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.amount - a.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表格列
|
||||||
|
const columns = [
|
||||||
|
{ title: '日期', dataIndex: 'transactionDate', key: 'transactionDate', width: 120 },
|
||||||
|
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
|
||||||
|
{ title: '描述', dataIndex: 'description', key: 'description' },
|
||||||
|
{ title: '分类', dataIndex: 'categoryId', key: 'categoryId', width: 120 },
|
||||||
|
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 150 },
|
||||||
|
{ title: '账户', dataIndex: 'accountId', key: 'accountId', width: 120 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const getCategoryName = (categoryId: number | null) => {
|
||||||
|
if (!categoryId) return '未分类';
|
||||||
|
const category = financeStore.getCategoryById(categoryId);
|
||||||
|
return category ? `${category.icon} ${category.name}` : '未知分类';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAccountName = (accountId: number) => {
|
||||||
|
const account = financeStore.getAccountById(accountId);
|
||||||
|
return account ? `${account.icon} ${account.name}` : '未知账户';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出报表
|
||||||
|
const handleExportReport = () => {
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
|
const title = exportOptions.value.title || '财务报表';
|
||||||
|
|
||||||
|
if (exportOptions.value.format === 'excel') {
|
||||||
|
exportToExcel(title, timestamp);
|
||||||
|
} else if (exportOptions.value.format === 'csv') {
|
||||||
|
exportToCSV(title, timestamp);
|
||||||
|
} else if (exportOptions.value.format === 'pdf') {
|
||||||
|
notification.info({
|
||||||
|
message: 'PDF 格式',
|
||||||
|
description: 'PDF 导出功能开发中,暂时使用 Excel 代替'
|
||||||
|
});
|
||||||
|
exportToExcel(title, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.success({
|
||||||
|
message: '导出成功',
|
||||||
|
description: `报表已成功导出`
|
||||||
|
});
|
||||||
|
|
||||||
|
showExportModal.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出失败:', error);
|
||||||
|
notification.error({
|
||||||
|
message: '导出失败',
|
||||||
|
description: '报表导出过程中出现错误'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出为 Excel
|
||||||
|
const exportToExcel = (title: string, timestamp: string) => {
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
// 核心指标汇总
|
||||||
|
if (exportOptions.value.includeSummary) {
|
||||||
|
const summaryData = [
|
||||||
|
['核心指标汇总', '', '', ''],
|
||||||
|
['指标', '金额', '', ''],
|
||||||
|
['总收入', `¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`, '', ''],
|
||||||
|
['总支出', `¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`, '', ''],
|
||||||
|
['净收入', `¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`, '', ''],
|
||||||
|
['交易笔数', periodTransactions.value.length, '', '']
|
||||||
|
];
|
||||||
|
const summarySheet = XLSX.utils.aoa_to_sheet(summaryData);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, summarySheet, '核心指标');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收入分析
|
||||||
|
if (exportOptions.value.includeIncome && incomeByCategory.value.length > 0) {
|
||||||
|
const incomeData = incomeByCategory.value.map(item => ({
|
||||||
|
'分类': item.categoryName,
|
||||||
|
'金额': item.amount,
|
||||||
|
'笔数': item.count,
|
||||||
|
'平均': (item.amount / item.count).toFixed(2),
|
||||||
|
'占比': `${item.percentage}%`
|
||||||
|
}));
|
||||||
|
const incomeSheet = XLSX.utils.json_to_sheet(incomeData);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, incomeSheet, '收入分析');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支出分析
|
||||||
|
if (exportOptions.value.includeExpense && expenseByCategory.value.length > 0) {
|
||||||
|
const expenseData = expenseByCategory.value.map(item => ({
|
||||||
|
'分类': item.categoryName,
|
||||||
|
'金额': item.amount,
|
||||||
|
'笔数': item.count,
|
||||||
|
'平均': (item.amount / item.count).toFixed(2),
|
||||||
|
'占比': `${item.percentage}%`
|
||||||
|
}));
|
||||||
|
const expenseSheet = XLSX.utils.json_to_sheet(expenseData);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, expenseSheet, '支出分析');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交易明细
|
||||||
|
if (exportOptions.value.includeTransactions && periodTransactions.value.length > 0) {
|
||||||
|
const transactionsData = periodTransactions.value.map(t => ({
|
||||||
|
'日期': t.transactionDate,
|
||||||
|
'类型': t.type === 'income' ? '收入' : '支出',
|
||||||
|
'描述': t.description || '',
|
||||||
|
'分类': getCategoryName(t.categoryId),
|
||||||
|
'金额': t.amount,
|
||||||
|
'币种': t.currency,
|
||||||
|
'账户': getAccountName(t.accountId)
|
||||||
|
}));
|
||||||
|
const transactionsSheet = XLSX.utils.json_to_sheet(transactionsData);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, transactionsSheet, '交易明细');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成文件
|
||||||
|
XLSX.writeFile(workbook, `${title}-${timestamp}.xlsx`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出为 CSV
|
||||||
|
const exportToCSV = (title: string, timestamp: string) => {
|
||||||
|
let csvContent = '';
|
||||||
|
|
||||||
|
// 核心指标汇总
|
||||||
|
if (exportOptions.value.includeSummary) {
|
||||||
|
csvContent += '核心指标汇总\n';
|
||||||
|
csvContent += '指标,金额\n';
|
||||||
|
csvContent += `总收入,¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||||
|
csvContent += `总支出,¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||||
|
csvContent += `净收入,¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||||
|
csvContent += `交易笔数,${periodTransactions.value.length}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交易明细
|
||||||
|
if (exportOptions.value.includeTransactions && periodTransactions.value.length > 0) {
|
||||||
|
csvContent += '交易明细\n';
|
||||||
|
csvContent += '日期,类型,描述,分类,金额,币种,账户\n';
|
||||||
|
periodTransactions.value.forEach(t => {
|
||||||
|
csvContent += `${t.transactionDate},${t.type === 'income' ? '收入' : '支出'},"${t.description || ''}",${getCategoryName(t.categoryId)},${t.amount},${t.currency},${getAccountName(t.accountId)}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载文件
|
||||||
|
const BOM = '\uFEFF';
|
||||||
|
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${title}-${timestamp}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打印报表
|
||||||
|
const printReport = () => {
|
||||||
|
try {
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) {
|
||||||
|
notification.error({
|
||||||
|
message: '打印失败',
|
||||||
|
description: '无法打开打印窗口,请检查浏览器设置'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodText = {
|
||||||
|
month: '本月',
|
||||||
|
quarter: '本季度',
|
||||||
|
year: '本年',
|
||||||
|
all: '全部'
|
||||||
|
}[period.value] || '自定义';
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>财务报表 - ${periodText}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||||
|
h1 { text-align: center; color: #333; }
|
||||||
|
h2 { color: #666; margin-top: 30px; border-bottom: 2px solid #eee; padding-bottom: 10px; }
|
||||||
|
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin: 20px 0; }
|
||||||
|
.summary-card { border: 1px solid #eee; padding: 15px; border-radius: 8px; text-align: center; }
|
||||||
|
.summary-card .label { color: #888; font-size: 14px; }
|
||||||
|
.summary-card .value { font-size: 24px; font-weight: bold; margin-top: 5px; }
|
||||||
|
.income { color: #52c41a; }
|
||||||
|
.expense { color: #f5222d; }
|
||||||
|
.net { color: #722ed1; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||||
|
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
|
||||||
|
th { background-color: #f5f5f5; font-weight: bold; }
|
||||||
|
tr:nth-child(even) { background-color: #fafafa; }
|
||||||
|
.category-item { margin: 10px 0; padding: 10px; background: #f9f9f9; border-radius: 5px; }
|
||||||
|
.category-name { font-weight: bold; }
|
||||||
|
.category-amount { float: right; }
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>📈 财务报表</h1>
|
||||||
|
<p style="text-align: center; color: #888;">报表周期: ${periodText} · 生成时间: ${new Date().toLocaleString('zh-CN')}</p>
|
||||||
|
|
||||||
|
<h2>核心指标汇总</h2>
|
||||||
|
<div class="summary">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="label">总收入</div>
|
||||||
|
<div class="value income">¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="label">总支出</div>
|
||||||
|
<div class="value expense">¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="label">净收入</div>
|
||||||
|
<div class="value net">${periodNet.value >= 0 ? '+' : ''}¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="label">交易笔数</div>
|
||||||
|
<div class="value">${periodTransactions.value.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${incomeByCategory.value.length > 0 ? `
|
||||||
|
<h2>收入分析</h2>
|
||||||
|
<div>
|
||||||
|
${incomeByCategory.value.map(item => `
|
||||||
|
<div class="category-item">
|
||||||
|
<span class="category-name">${item.categoryName}</span>
|
||||||
|
<span class="category-amount income">¥${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||||
|
<div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;">
|
||||||
|
${item.count} 笔 · 平均 ¥${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${expenseByCategory.value.length > 0 ? `
|
||||||
|
<h2>支出分析</h2>
|
||||||
|
<div>
|
||||||
|
${expenseByCategory.value.map(item => `
|
||||||
|
<div class="category-item">
|
||||||
|
<span class="category-name">${item.categoryName}</span>
|
||||||
|
<span class="category-amount expense">¥${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||||
|
<div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;">
|
||||||
|
${item.count} 笔 · 平均 ¥${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${periodTransactions.value.length > 0 ? `
|
||||||
|
<h2>交易明细</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>日期</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>描述</th>
|
||||||
|
<th>分类</th>
|
||||||
|
<th>金额</th>
|
||||||
|
<th>账户</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${periodTransactions.value.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td>${t.transactionDate}</td>
|
||||||
|
<td>${t.type === 'income' ? '📈 收入' : '📉 支出'}</td>
|
||||||
|
<td>${t.description || ''}</td>
|
||||||
|
<td>${getCategoryName(t.categoryId)}</td>
|
||||||
|
<td class="${t.type === 'income' ? 'income' : 'expense'}">
|
||||||
|
${t.type === 'income' ? '+' : '-'}¥${Math.abs(t.amount).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td>${getAccountName(t.accountId)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="no-print" style="text-align: center; margin-top: 40px;">
|
||||||
|
<button onclick="window.print()" style="padding: 10px 30px; font-size: 16px; cursor: pointer;">🖨️ 打印</button>
|
||||||
|
<button onclick="window.close()" style="padding: 10px 30px; font-size: 16px; margin-left: 10px; cursor: pointer;">关闭</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('打印失败:', error);
|
||||||
|
notification.error({
|
||||||
|
message: '打印失败',
|
||||||
|
description: '生成打印预览时出现错误'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理报表文件上传
|
||||||
|
const handleReportFileUpload = async (event: Event) => {
|
||||||
|
const file = (event.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileExtension = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
|
if (fileExtension === 'json') {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
importPreviewData.value = Array.isArray(data) ? data : [data];
|
||||||
|
} else if (fileExtension === 'csv') {
|
||||||
|
const text = await file.text();
|
||||||
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
if (lines.length < 2) throw new Error('CSV 文件格式不正确');
|
||||||
|
|
||||||
|
const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
|
||||||
|
const data = lines.slice(1).map(line => {
|
||||||
|
const values = line.split(',').map(v => v.trim().replace(/^"|"$/g, ''));
|
||||||
|
const row: any = {};
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
row[header] = values[index] || '';
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
importPreviewData.value = data;
|
||||||
|
} else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||||
|
const firstSheetName = workbook.SheetNames[0];
|
||||||
|
const worksheet = workbook.Sheets[firstSheetName];
|
||||||
|
const data = XLSX.utils.sheet_to_json(worksheet);
|
||||||
|
importPreviewData.value = data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('文件解析失败:', error);
|
||||||
|
notification.error({
|
||||||
|
message: '文件解析失败',
|
||||||
|
description: '请检查文件格式是否正确'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理导入报表
|
||||||
|
const handleImportReport = async () => {
|
||||||
|
try {
|
||||||
|
if (importPreviewData.value.length === 0) {
|
||||||
|
notification.warning({
|
||||||
|
message: '无数据',
|
||||||
|
description: '请先上传文件'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const row of importPreviewData.value) {
|
||||||
|
try {
|
||||||
|
const type = row.type || row['类型'];
|
||||||
|
const typeValue = type === '收入' || type === 'income' ? 'income' : 'expense';
|
||||||
|
|
||||||
|
// 查找分类
|
||||||
|
const categoryName = (row.category || row['分类'] || '').replace(/[^\u4e00-\u9fa5a-zA-Z]/g, '');
|
||||||
|
const categories = typeValue === 'income'
|
||||||
|
? financeStore.incomeCategories
|
||||||
|
: financeStore.expenseCategories;
|
||||||
|
const category = categories.find(c => c.name === categoryName);
|
||||||
|
|
||||||
|
// 查找账户 - 使用默认账户
|
||||||
|
const defaultAccount = financeStore.accounts.find(a => a.currency === (row.currency || row['币种'] || 'CNY'));
|
||||||
|
|
||||||
|
if (!defaultAccount) {
|
||||||
|
failCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await financeStore.createTransaction({
|
||||||
|
type: typeValue,
|
||||||
|
amount: Number(row.amount || row['金额']),
|
||||||
|
currency: row.currency || row['币种'] || 'CNY',
|
||||||
|
categoryId: category?.id,
|
||||||
|
accountId: defaultAccount.id,
|
||||||
|
transactionDate: row.date || row['日期'],
|
||||||
|
description: row.description || row['描述'] || row['项目名称'] || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导入单条数据失败:', error);
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.success({
|
||||||
|
message: '导入完成',
|
||||||
|
description: `成功导入 ${successCount} 条,失败 ${failCount} 条`
|
||||||
|
});
|
||||||
|
|
||||||
|
showImportModal.value = false;
|
||||||
|
importPreviewData.value = [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导入失败:', error);
|
||||||
|
notification.error({
|
||||||
|
message: '导入失败',
|
||||||
|
description: '数据导入过程中出现错误'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
financeStore.initializeData(),
|
||||||
|
financeStore.fetchTransactions(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -8,17 +8,6 @@
|
|||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card title="🔧 基本设置">
|
<Card title="🔧 基本设置">
|
||||||
<Form :model="settings" layout="vertical">
|
<Form :model="settings" layout="vertical">
|
||||||
<Form.Item label="默认货币">
|
|
||||||
<Select v-model:value="settings.defaultCurrency" style="width: 100%" @change="saveCurrencySettings">
|
|
||||||
<Select.Option value="CNY">🇨🇳 人民币 (CNY)</Select.Option>
|
|
||||||
<Select.Option value="USD">🇺🇸 美元 (USD)</Select.Option>
|
|
||||||
<Select.Option value="EUR">🇪🇺 欧元 (EUR)</Select.Option>
|
|
||||||
<Select.Option value="JPY">🇯🇵 日元 (JPY)</Select.Option>
|
|
||||||
<Select.Option value="GBP">🇬🇧 英镑 (GBP)</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
|
|
||||||
<Divider>通知设置</Divider>
|
<Divider>通知设置</Divider>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
1038
apps/web-antd/src/views/finance/statistics/index.vue
Normal file
@@ -9,8 +9,7 @@ export default defineConfig(async () => {
|
|||||||
'/api': {
|
'/api': {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
// mock代理目标地址
|
target: 'http://localhost:3000/api',
|
||||||
target: 'http://localhost:5320/api',
|
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
# 应用标题
|
|
||||||
VITE_APP_TITLE=Vben Admin Antd
|
|
||||||
|
|
||||||
# 应用命名空间,用于缓存、store等功能的前缀,确保隔离
|
|
||||||
VITE_APP_NAMESPACE=vben-web-antd
|
|
||||||
|
|
||||||
# 对store进行加密的密钥,在将store持久化到localStorage时会使用该密钥进行加密
|
|
||||||
VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# public path
|
|
||||||
VITE_BASE=/
|
|
||||||
|
|
||||||
# Basic interface address SPA
|
|
||||||
VITE_GLOB_API_URL=/api
|
|
||||||
|
|
||||||
VITE_VISUALIZER=true
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# 端口号
|
|
||||||
VITE_PORT=3000
|
|
||||||
|
|
||||||
VITE_BASE=/
|
|
||||||
|
|
||||||
# 接口地址
|
|
||||||
VITE_GLOB_API_URL=/api
|
|
||||||
|
|
||||||
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
|
|
||||||
VITE_NITRO_MOCK=false
|
|
||||||
|
|
||||||
# 是否打开 devtools,true 为打开,false 为关闭
|
|
||||||
VITE_DEVTOOLS=false
|
|
||||||
|
|
||||||
# 是否注入全局loading
|
|
||||||
VITE_INJECT_APP_LOADING=true
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
VITE_BASE=/
|
|
||||||
|
|
||||||
# 接口地址
|
|
||||||
VITE_GLOB_API_URL=https://mock-napi.vben.pro/api
|
|
||||||
|
|
||||||
# 是否开启压缩,可以设置为 none, brotli, gzip
|
|
||||||
VITE_COMPRESS=none
|
|
||||||
|
|
||||||
# 是否开启 PWA
|
|
||||||
VITE_PWA=false
|
|
||||||
|
|
||||||
# vue-router 的模式
|
|
||||||
VITE_ROUTER_HISTORY=hash
|
|
||||||
|
|
||||||
# 是否注入全局loading
|
|
||||||
VITE_INJECT_APP_LOADING=true
|
|
||||||
|
|
||||||
# 打包后是否生成dist.zip
|
|
||||||
VITE_ARCHIVER=true
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# TokenRecords 财务管理系统 (VbenAdmin 版本)
|
|
||||||
|
|
||||||
基于 VbenAdmin 框架构建的现代化财务管理系统,提供完整的收支记录、分类管理、人员管理和贷款管理功能。
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
### 核心功能
|
|
||||||
|
|
||||||
- **交易管理**:记录和管理所有收支交易,支持多币种、多状态管理
|
|
||||||
- **分类管理**:灵活的收支分类体系,支持自定义分类
|
|
||||||
- **人员管理**:管理交易相关人员,支持多角色(付款人、收款人、借款人、出借人)
|
|
||||||
- **贷款管理**:完整的贷款和还款记录管理,自动计算还款进度
|
|
||||||
|
|
||||||
### 技术特性
|
|
||||||
|
|
||||||
- **现代化技术栈**:Vue 3 + TypeScript + Vite + Pinia + Ant Design Vue
|
|
||||||
- **本地存储**:使用 IndexedDB 进行数据持久化,支持离线使用
|
|
||||||
- **Mock API**:完整的 Mock 数据服务,方便开发和测试
|
|
||||||
- **响应式设计**:适配各种屏幕尺寸
|
|
||||||
- **国际化支持**:内置中文语言包,可扩展多语言
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 启动开发服务器
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev:finance
|
|
||||||
```
|
|
||||||
|
|
||||||
### 访问系统
|
|
||||||
|
|
||||||
- 开发地址:http://localhost:5666/
|
|
||||||
- 默认账号:vben
|
|
||||||
- 默认密码:123456
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── api/ # API 接口
|
|
||||||
│ ├── finance/ # 财务相关 API
|
|
||||||
│ └── mock/ # Mock 数据服务
|
|
||||||
├── store/ # 状态管理
|
|
||||||
│ └── modules/ # 业务模块
|
|
||||||
├── types/ # TypeScript 类型定义
|
|
||||||
├── utils/ # 工具函数
|
|
||||||
│ ├── db.ts # IndexedDB 工具
|
|
||||||
│ └── data-migration.ts # 数据迁移工具
|
|
||||||
├── views/ # 页面组件
|
|
||||||
│ ├── finance/ # 财务管理页面
|
|
||||||
│ ├── analytics/ # 统计分析页面
|
|
||||||
│ └── tools/ # 系统工具页面
|
|
||||||
├── router/ # 路由配置
|
|
||||||
└── locales/ # 国际化配置
|
|
||||||
```
|
|
||||||
|
|
||||||
## 数据存储
|
|
||||||
|
|
||||||
系统使用 IndexedDB 作为本地存储方案,支持:
|
|
||||||
|
|
||||||
- 自动数据持久化
|
|
||||||
- 事务支持
|
|
||||||
- 索引查询
|
|
||||||
- 数据备份和恢复
|
|
||||||
|
|
||||||
### 数据迁移
|
|
||||||
|
|
||||||
如果您有旧版本的数据(存储在 localStorage),系统会在启动时自动检测并迁移到新的存储系统。
|
|
||||||
|
|
||||||
## 开发指南
|
|
||||||
|
|
||||||
### 添加新功能
|
|
||||||
|
|
||||||
1. 在 `types/finance.ts` 中定义数据类型
|
|
||||||
2. 在 `api/finance/` 中创建 API 接口
|
|
||||||
3. 在 `store/modules/` 中创建状态管理
|
|
||||||
4. 在 `views/` 中创建页面组件
|
|
||||||
5. 在 `router/routes/modules/` 中配置路由
|
|
||||||
|
|
||||||
### Mock 数据
|
|
||||||
|
|
||||||
Mock 数据服务位于 `api/mock/finance-service.ts`,可以根据需要修改初始数据或添加新的 Mock 接口。
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
运行 Playwright 测试:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node test-finance-system.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## 部署
|
|
||||||
|
|
||||||
### 构建生产版本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm build:finance
|
|
||||||
```
|
|
||||||
|
|
||||||
构建产物将生成在 `dist` 目录中。
|
|
||||||
|
|
||||||
## 技术支持
|
|
||||||
|
|
||||||
- VbenAdmin 文档:https://doc.vben.pro/
|
|
||||||
- Vue 3 文档:https://cn.vuejs.org/
|
|
||||||
- Ant Design Vue:https://antdv.com/
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB |
@@ -1,64 +0,0 @@
|
|||||||
import { chromium } from 'playwright';
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: false, // 有头模式,方便观察
|
|
||||||
});
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
// 监听控制台消息
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
console.log(`浏览器控制台 [${msg.type()}]:`, msg.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听页面错误
|
|
||||||
page.on('pageerror', (error) => {
|
|
||||||
console.error('页面错误:', error.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('正在访问 http://localhost:5666/ ...\n');
|
|
||||||
|
|
||||||
const response = await page.goto('http://localhost:5666/', {
|
|
||||||
waitUntil: 'domcontentloaded',
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('响应状态:', response?.status());
|
|
||||||
console.log('当前URL:', page.url());
|
|
||||||
|
|
||||||
// 等待页面加载
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
// 截图查看页面状态
|
|
||||||
await page.screenshot({
|
|
||||||
path: 'server-check.png',
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
console.log('\n已保存截图: server-check.png');
|
|
||||||
|
|
||||||
// 检查页面内容
|
|
||||||
const title = await page.title();
|
|
||||||
console.log('页面标题:', title);
|
|
||||||
|
|
||||||
// 检查是否有错误信息
|
|
||||||
const bodyText = await page.locator('body').textContent();
|
|
||||||
console.log('\n页面内容预览:');
|
|
||||||
console.log(`${bodyText.slice(0, 500)}...`);
|
|
||||||
|
|
||||||
// 保持浏览器打开10秒以便查看
|
|
||||||
console.log('\n浏览器将在10秒后关闭...');
|
|
||||||
await page.waitForTimeout(10_000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('访问失败:', error.message);
|
|
||||||
|
|
||||||
// 尝试获取更多错误信息
|
|
||||||
if (error.message.includes('ERR_CONNECTION_REFUSED')) {
|
|
||||||
console.log('\n服务器可能未启动或端口错误');
|
|
||||||
console.log('检查端口 5666 是否被占用...');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 97 KiB |
@@ -1,90 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
|
||||||
<meta name="renderer" content="webkit" />
|
|
||||||
<meta name="description" content="TokenRecords 财务管理系统" />
|
|
||||||
<meta name="keywords" content="TokenRecords Finance Management Vue3" />
|
|
||||||
<meta name="author" content="TokenRecords" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
|
|
||||||
/>
|
|
||||||
<title>TokenRecords 财务管理系统</title>
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|
||||||
.loading { text-align: center; padding: 50px; }
|
|
||||||
.success { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px; border-radius: 12px; margin: 20px 0; }
|
|
||||||
.error { background: #fee; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0; border: 1px solid #fcc; }
|
|
||||||
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin: 30px 0; }
|
|
||||||
.feature-card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); text-align: center; }
|
|
||||||
.btn { display: inline-block; padding: 10px 20px; margin: 10px 5px; background: #007bff; color: white; text-decoration: none; border-radius: 5px; }
|
|
||||||
.btn:hover { background: #0056b3; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="fallback" class="loading">
|
|
||||||
<h1>🚀 正在启动 TokenRecords 财务管理系统...</h1>
|
|
||||||
<p>如果页面长时间不加载,请检查浏览器控制台或尝试刷新页面。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="app"></div>
|
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 如果Vue应用在3秒内没有加载成功,显示静态备用页面
|
|
||||||
setTimeout(function() {
|
|
||||||
const app = document.getElementById('app');
|
|
||||||
const fallback = document.getElementById('fallback');
|
|
||||||
|
|
||||||
if (app && !app.innerHTML.trim()) {
|
|
||||||
console.log('Vue应用可能加载失败,显示静态备用页面');
|
|
||||||
fallback.innerHTML = `
|
|
||||||
<div class="success">
|
|
||||||
<h1>🎉 TokenRecords 财务管理系统</h1>
|
|
||||||
<p>✅ 服务器运行正常 - 正在加载应用...</p>
|
|
||||||
<p>📊 端口: 5666 | API端口: 5320</p>
|
|
||||||
<p>⚡ Vue 3 + Vite + Ant Design Vue</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-grid">
|
|
||||||
<div class="feature-card">
|
|
||||||
<h3>📊 财务分析</h3>
|
|
||||||
<p>查看收支统计和趋势</p>
|
|
||||||
<a href="/analytics/overview" class="btn">进入分析</a>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<h3>💰 交易记录</h3>
|
|
||||||
<p>管理收入和支出</p>
|
|
||||||
<a href="/finance/transaction" class="btn">查看交易</a>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<h3>👥 人员管理</h3>
|
|
||||||
<p>管理付款人和收款人</p>
|
|
||||||
<a href="/finance/person" class="btn">管理人员</a>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<h3>📝 快速记账</h3>
|
|
||||||
<p>快速添加记录</p>
|
|
||||||
<a href="/quick-add" class="btn">开始记账</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="error">
|
|
||||||
<h3>⚠️ 如果您看到这个页面</h3>
|
|
||||||
<p>说明Vue应用可能存在JavaScript加载问题。请:</p>
|
|
||||||
<ol style="text-align: left; max-width: 500px; margin: 0 auto;">
|
|
||||||
<li>🔄 刷新页面试试</li>
|
|
||||||
<li>🛠️ 打开开发者工具查看控制台错误</li>
|
|
||||||
<li>🌐 或使用标准版本: <a href="http://localhost:5667/" style="color: #007bff;">http://localhost:5667/</a></li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
Before Width: | Height: | Size: 157 KiB |
@@ -1,73 +0,0 @@
|
|||||||
import { chromium } from 'playwright';
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: false, // 有头模式
|
|
||||||
devtools: true, // 打开开发者工具
|
|
||||||
});
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: { width: 1920, height: 1080 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
// 监听控制台消息
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') {
|
|
||||||
console.log('❌ 控制台错误:', msg.text());
|
|
||||||
} else if (msg.type() === 'warning') {
|
|
||||||
console.log('⚠️ 控制台警告:', msg.text());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听页面崩溃
|
|
||||||
page.on('crash', () => {
|
|
||||||
console.log('💥 页面崩溃了!');
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听网络错误
|
|
||||||
page.on('response', (response) => {
|
|
||||||
if (response.status() >= 400) {
|
|
||||||
console.log(`🚫 网络错误 [${response.status()}]: ${response.url()}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('=================================');
|
|
||||||
console.log('财务管理系统手动检查工具');
|
|
||||||
console.log('=================================\n');
|
|
||||||
|
|
||||||
console.log('正在打开系统...');
|
|
||||||
await page.goto('http://localhost:5666/', {
|
|
||||||
waitUntil: 'networkidle',
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n请手动执行以下操作:');
|
|
||||||
console.log('1. 登录系统(用户名: vben, 密码: 123456)');
|
|
||||||
console.log('2. 逐个点击以下菜单并检查是否正常:');
|
|
||||||
console.log(' - 财务管理 > 财务概览');
|
|
||||||
console.log(' - 财务管理 > 交易管理');
|
|
||||||
console.log(' - 财务管理 > 分类管理');
|
|
||||||
console.log(' - 财务管理 > 人员管理');
|
|
||||||
console.log(' - 财务管理 > 贷款管理');
|
|
||||||
console.log(' - 数据分析 > 数据概览');
|
|
||||||
console.log(' - 数据分析 > 趋势分析');
|
|
||||||
console.log(' - 系统工具 > 导入数据');
|
|
||||||
console.log(' - 系统工具 > 导出数据');
|
|
||||||
console.log(' - 系统工具 > 数据备份');
|
|
||||||
console.log(' - 系统工具 > 预算管理');
|
|
||||||
console.log(' - 系统工具 > 标签管理');
|
|
||||||
|
|
||||||
console.log('\n需要检查的内容:');
|
|
||||||
console.log('✓ 页面是否正常加载');
|
|
||||||
console.log('✓ 是否有错误提示');
|
|
||||||
console.log('✓ 表格是否显示正常');
|
|
||||||
console.log('✓ 按钮是否可以点击');
|
|
||||||
console.log('✓ 图表是否正常显示(数据分析页面)');
|
|
||||||
|
|
||||||
console.log('\n控制台将实时显示错误信息...');
|
|
||||||
console.log('按 Ctrl+C 结束检查\n');
|
|
||||||
|
|
||||||
// 保持浏览器开启
|
|
||||||
await new Promise(() => {});
|
|
||||||
})();
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@vben/web-finance",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"homepage": "https://vben.pro",
|
|
||||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
|
||||||
"directory": "apps/web-antd"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"author": {
|
|
||||||
"name": "vben",
|
|
||||||
"email": "ann.vben@gmail.com",
|
|
||||||
"url": "https://github.com/anncwb"
|
|
||||||
},
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "pnpm vite build --mode production",
|
|
||||||
"build:analyze": "pnpm vite build --mode analyze",
|
|
||||||
"dev": "pnpm vite --mode development",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"typecheck": "vue-tsc --noEmit --skipLibCheck"
|
|
||||||
},
|
|
||||||
"imports": {
|
|
||||||
"#/*": "./src/*"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"@vben/access": "workspace:*",
|
|
||||||
"@vben/common-ui": "workspace:*",
|
|
||||||
"@vben/constants": "workspace:*",
|
|
||||||
"@vben/hooks": "workspace:*",
|
|
||||||
"@vben/icons": "workspace:*",
|
|
||||||
"@vben/layouts": "workspace:*",
|
|
||||||
"@vben/locales": "workspace:*",
|
|
||||||
"@vben/plugins": "workspace:*",
|
|
||||||
"@vben/preferences": "workspace:*",
|
|
||||||
"@vben/request": "workspace:*",
|
|
||||||
"@vben/stores": "workspace:*",
|
|
||||||
"@vben/styles": "workspace:*",
|
|
||||||
"@vben/types": "workspace:*",
|
|
||||||
"@vben/utils": "workspace:*",
|
|
||||||
"@vueuse/core": "catalog:",
|
|
||||||
"ant-design-vue": "catalog:",
|
|
||||||
"dayjs": "catalog:",
|
|
||||||
"echarts": "catalog:",
|
|
||||||
"pinia": "catalog:",
|
|
||||||
"uuid": "^11.1.0",
|
|
||||||
"vue": "catalog:",
|
|
||||||
"vue-echarts": "^7.0.3",
|
|
||||||
"vue-router": "catalog:"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from '@vben/tailwind-config/postcss';
|
|
||||||
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -1,246 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>TokenRecords 财务管理系统 - 主页</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
text-align: center;
|
|
||||||
color: white;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
padding: 40px 0;
|
|
||||||
}
|
|
||||||
.header h1 {
|
|
||||||
font-size: 3rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
.header p {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
.success-banner {
|
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 30px;
|
|
||||||
margin: 20px 0;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.success-banner h2 {
|
|
||||||
color: #28a745;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
.feature-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
||||||
gap: 25px;
|
|
||||||
margin: 30px 0;
|
|
||||||
}
|
|
||||||
.feature-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 30px 20px;
|
|
||||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
|
||||||
text-align: center;
|
|
||||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
}
|
|
||||||
.feature-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
box-shadow: 0 15px 40px rgba(0,0,0,0.15);
|
|
||||||
border-color: #667eea;
|
|
||||||
}
|
|
||||||
.feature-icon {
|
|
||||||
font-size: 3rem;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.feature-card h3 {
|
|
||||||
color: #2c3e50;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
}
|
|
||||||
.feature-card p {
|
|
||||||
color: #7f8c8d;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 12px 24px;
|
|
||||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 25px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn:hover {
|
|
||||||
background: linear-gradient(45deg, #5a6fd8, #6b4190);
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
.status-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 25px;
|
|
||||||
margin-top: 30px;
|
|
||||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.status-item {
|
|
||||||
text-align: center;
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
.status-value {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
.status-label {
|
|
||||||
color: #7f8c8d;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.working { color: #28a745; }
|
|
||||||
.info { color: #007bff; }
|
|
||||||
.warning { color: #ffc107; }
|
|
||||||
.time { color: #17a2b8; }
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 40px;
|
|
||||||
color: rgba(255,255,255,0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.header h1 { font-size: 2rem; }
|
|
||||||
.container { padding: 10px; }
|
|
||||||
.feature-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>🎉 TokenRecords 财务管理系统</h1>
|
|
||||||
<p>基于 Vue 3 + Vite + Ant Design Vue 的现代化财务管理平台</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="success-banner">
|
|
||||||
<h2>✅ 系统启动成功!</h2>
|
|
||||||
<p><strong>恭喜!</strong> 您的财务管理系统已成功部署并正在运行。</p>
|
|
||||||
<p>🕒 启动时间: <span id="time"></span></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-grid">
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">📊</div>
|
|
||||||
<h3>财务分析</h3>
|
|
||||||
<p>查看详细的收支统计、趋势分析和财务报表</p>
|
|
||||||
<a href="/analytics/overview" class="btn">进入分析</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">💰</div>
|
|
||||||
<h3>交易记录</h3>
|
|
||||||
<p>管理所有收入和支出记录,支持批量导入导出</p>
|
|
||||||
<a href="/finance/transaction" class="btn">管理交易</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">📝</div>
|
|
||||||
<h3>快速记账</h3>
|
|
||||||
<p>快速添加收支记录,支持智能分类和标签</p>
|
|
||||||
<a href="/quick-add" class="btn">快速记账</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">👥</div>
|
|
||||||
<h3>人员管理</h3>
|
|
||||||
<p>管理付款人、收款人和相关联系信息</p>
|
|
||||||
<a href="/finance/person" class="btn">管理人员</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🏷️</div>
|
|
||||||
<h3>分类设置</h3>
|
|
||||||
<p>设置和管理收支分类,支持层级结构</p>
|
|
||||||
<a href="/finance/category" class="btn">分类设置</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">⚙️</div>
|
|
||||||
<h3>系统设置</h3>
|
|
||||||
<p>配置系统参数、用户权限和数据备份</p>
|
|
||||||
<a href="/settings" class="btn">系统设置</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-grid">
|
|
||||||
<div class="status-item">
|
|
||||||
<div class="status-value working">正常运行</div>
|
|
||||||
<div class="status-label">系统状态</div>
|
|
||||||
</div>
|
|
||||||
<div class="status-item">
|
|
||||||
<div class="status-value info">5666</div>
|
|
||||||
<div class="status-label">Web端口</div>
|
|
||||||
</div>
|
|
||||||
<div class="status-item">
|
|
||||||
<div class="status-value info">5320</div>
|
|
||||||
<div class="status-label">API端口</div>
|
|
||||||
</div>
|
|
||||||
<div class="status-item">
|
|
||||||
<div class="status-value warning">Vue 3.5</div>
|
|
||||||
<div class="status-label">前端版本</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<p>🚀 TokenRecords 财务管理系统 | 基于 Vben Admin 架构</p>
|
|
||||||
<p>💡 如需帮助,请查看开发者工具控制台 | 标准版本: <a href="http://localhost:5667/" style="color: #ffeb3b;">http://localhost:5667/</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 更新时间显示
|
|
||||||
function updateTime() {
|
|
||||||
const now = new Date();
|
|
||||||
document.getElementById('time').textContent = now.toLocaleString('zh-CN');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTime();
|
|
||||||
setInterval(updateTime, 1000);
|
|
||||||
|
|
||||||
console.log('🎉 TokenRecords 静态页面加载成功!');
|
|
||||||
console.log('📊 系统信息:');
|
|
||||||
console.log(' - Web端口: 5666');
|
|
||||||
console.log(' - API端口: 5320');
|
|
||||||
console.log(' - 状态: 正常运行');
|
|
||||||
|
|
||||||
// 检查Vue应用是否也在加载
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('💡 提示: 如果您能看到这个静态页面,说明服务器工作正常');
|
|
||||||
console.log('🔧 可以点击上面的功能按钮来测试各个模块');
|
|
||||||
}, 1000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { chromium } from 'playwright';
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: false, // 有头模式,方便观察
|
|
||||||
});
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
console.log('快速测试导入导出功能...\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 直接访问交易管理页面
|
|
||||||
console.log('访问交易管理页面...');
|
|
||||||
await page.goto('http://localhost:5666/finance/transaction');
|
|
||||||
|
|
||||||
// 等待页面加载
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
// 截图
|
|
||||||
await page.screenshot({ path: 'transaction-page.png' });
|
|
||||||
console.log('页面截图已保存为 transaction-page.png');
|
|
||||||
|
|
||||||
// 测试导出CSV
|
|
||||||
console.log('\n尝试导出CSV...');
|
|
||||||
try {
|
|
||||||
const exportBtn = page.locator('button:has-text("导出数据")');
|
|
||||||
if (await exportBtn.isVisible()) {
|
|
||||||
await exportBtn.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// 点击CSV导出
|
|
||||||
await page.locator('text="导出为CSV"').click();
|
|
||||||
console.log('CSV导出操作已触发');
|
|
||||||
} else {
|
|
||||||
console.log('导出按钮未找到');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.log('导出功能可能需要登录');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n测试完成!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('测试失败:', error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保持浏览器打开20秒供查看
|
|
||||||
console.log('\n浏览器将在20秒后关闭...');
|
|
||||||
await page.waitForTimeout(20_000);
|
|
||||||
await browser.close();
|
|
||||||
})();
|
|
||||||