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

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

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

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

View File

@@ -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;
}

View 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);
});

View 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);
});

View 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);
});

View 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: '删除成功' });
});

View 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);
});

View 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);
});

View 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);
});

View 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: '删除成功' });
});

View 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);
});

View File

@@ -0,0 +1,6 @@
import { listCurrencies } from '~/utils/finance-metadata';
import { useResponseSuccess } from '~/utils/response';
export default defineEventHandler(async () => {
return useResponseSuccess(listCurrencies());
});

View 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);
});

View 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);
});

View 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);
});

View 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: '删除成功' });
});

View 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);
});

View File

@@ -1,5 +1,5 @@
{
"name": "@vben/backend-mock",
"name": "@vben/backend",
"version": "0.0.1",
"description": "",
"private": true,
@@ -7,10 +7,12 @@
"author": "",
"scripts": {
"build": "nitro build",
"start": "nitro dev"
"start": "nitro dev",
"import:data": "node scripts/import-finance-data.js"
},
"dependencies": {
"@faker-js/faker": "catalog:",
"better-sqlite3": "9.5.0",
"jsonwebtoken": "catalog:",
"nitropack": "catalog:"
},

View 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} 个。`);

View 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;
}

View 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);
}

File diff suppressed because it is too large Load Diff

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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

View File

@@ -1,7 +0,0 @@
# public path
VITE_BASE=/
# Basic interface address SPA
VITE_GLOB_API_URL=/api
VITE_VISUALIZER=true

View File

@@ -1,16 +0,0 @@
# 端口号
VITE_PORT=3000
VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=/api
# 是否开启 Nitro Mock服务true 为开启false 为关闭
VITE_NITRO_MOCK=false
# 是否打开 devtoolstrue 为打开false 为关闭
VITE_DEVTOOLS=false
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true

View File

@@ -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

View File

@@ -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 Vuehttps://antdv.com/
## 许可证
MIT

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -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();
}
})();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

View File

@@ -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(() => {});
})();

View File

@@ -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:"
}
}

View File

@@ -1 +0,0 @@
export { default } from '@vben/tailwind-config/postcss';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -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>

View File

@@ -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();
})();

View File

@@ -1,211 +0,0 @@
/**
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/
import type { Component } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { notification } from 'ant-design-vue';
const AutoComplete = defineAsyncComponent(
() => import('ant-design-vue/es/auto-complete'),
);
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
const Checkbox = defineAsyncComponent(
() => import('ant-design-vue/es/checkbox'),
);
const CheckboxGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
);
const DatePicker = defineAsyncComponent(
() => import('ant-design-vue/es/date-picker'),
);
const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
const InputNumber = defineAsyncComponent(
() => import('ant-design-vue/es/input-number'),
);
const InputPassword = defineAsyncComponent(() =>
import('ant-design-vue/es/input').then((res) => res.InputPassword),
);
const Mentions = defineAsyncComponent(
() => import('ant-design-vue/es/mentions'),
);
const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
const RadioGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
);
const RangePicker = defineAsyncComponent(() =>
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
);
const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
const Textarea = defineAsyncComponent(() =>
import('ant-design-vue/es/input').then((res) => res.Textarea),
);
const TimePicker = defineAsyncComponent(
() => import('ant-design-vue/es/time-picker'),
);
const TreeSelect = defineAsyncComponent(
() => import('ant-design-vue/es/tree-select'),
);
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
const withDefaultPlaceholder = <T extends Component>(
component: T,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
return defineComponent({
name: component.name,
inheritAttrs: false,
setup: (props: any, { attrs, expose, slots }) => {
const placeholder =
props?.placeholder ||
attrs?.placeholder ||
$t(`ui.placeholder.${type}`);
// 透传组件暴露的方法
const innerRef = ref();
expose(
new Proxy(
{},
{
get: (_target, key) => innerRef.value?.[key],
has: (_target, key) => key in (innerRef.value || {}),
},
),
);
return () =>
h(
component,
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
slots,
);
},
});
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'ApiSelect'
| 'ApiTreeSelect'
| 'AutoComplete'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
| 'DefaultButton'
| 'Divider'
| 'IconPicker'
| 'Input'
| 'InputNumber'
| 'InputPassword'
| 'Mentions'
| 'PrimaryButton'
| 'Radio'
| 'RadioGroup'
| 'RangePicker'
| 'Rate'
| 'Select'
| 'Space'
| 'Switch'
| 'Textarea'
| 'TimePicker'
| 'TreeSelect'
| 'Upload'
| BaseFormComponentType;
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
ApiSelect: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiSelect',
},
'select',
{
component: Select,
loadingSlot: 'suffixIcon',
visibleEvent: 'onDropdownVisibleChange',
modelPropName: 'value',
},
),
ApiTreeSelect: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiTreeSelect',
},
'select',
{
component: TreeSelect,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
},
),
AutoComplete,
Checkbox,
CheckboxGroup,
DatePicker,
// 自定义默认按钮
DefaultButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'default' }, slots);
},
Divider,
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
iconSlot: 'addonAfter',
inputComponent: Input,
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
Mentions: withDefaultPlaceholder(Mentions, 'input'),
// 自定义主要按钮
PrimaryButton: (props, { attrs, slots }) => {
return h(Button, { ...props, attrs, type: 'primary' }, slots);
},
Radio,
RadioGroup,
RangePicker,
Rate,
Select: withDefaultPlaceholder(Select, 'select'),
Space,
Switch,
Textarea: withDefaultPlaceholder(Textarea, 'input'),
TimePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
};
// 将组件注册到全局共享状态中
globalShareState.setComponents(components);
// 定义全局共享状态中的消息提示
globalShareState.defineMessage({
// 复制成功消息提示
copyPreferencesSuccess: (title, content) => {
notification.success({
description: content,
message: title,
placement: 'bottomRight',
});
},
});
}
export { initComponentAdapter };

Some files were not shown because too many files have changed in this diff Show More