diff --git a/.gitignore b/.gitignore index c2a8a771..e285a524 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ coverage **/.vitepress/cache .cache .turbo +.vercel +storage/ .temp dev-dist .stylelintcache diff --git a/apps/backend-mock/utils/mock-data.ts b/apps/backend-mock/utils/mock-data.ts deleted file mode 100644 index e73189a2..00000000 --- a/apps/backend-mock/utils/mock-data.ts +++ /dev/null @@ -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; -} diff --git a/apps/backend-mock/.env b/apps/backend/.env similarity index 100% rename from apps/backend-mock/.env rename to apps/backend/.env diff --git a/apps/backend-mock/README.md b/apps/backend/README.md similarity index 100% rename from apps/backend-mock/README.md rename to apps/backend/README.md diff --git a/apps/backend-mock/api/auth/codes.ts b/apps/backend/api/auth/codes.ts similarity index 100% rename from apps/backend-mock/api/auth/codes.ts rename to apps/backend/api/auth/codes.ts diff --git a/apps/backend-mock/api/auth/login.post.ts b/apps/backend/api/auth/login.post.ts similarity index 100% rename from apps/backend-mock/api/auth/login.post.ts rename to apps/backend/api/auth/login.post.ts diff --git a/apps/backend-mock/api/auth/logout.post.ts b/apps/backend/api/auth/logout.post.ts similarity index 100% rename from apps/backend-mock/api/auth/logout.post.ts rename to apps/backend/api/auth/logout.post.ts diff --git a/apps/backend-mock/api/auth/refresh.post.ts b/apps/backend/api/auth/refresh.post.ts similarity index 100% rename from apps/backend-mock/api/auth/refresh.post.ts rename to apps/backend/api/auth/refresh.post.ts diff --git a/apps/backend-mock/api/demo/bigint.ts b/apps/backend/api/demo/bigint.ts similarity index 100% rename from apps/backend-mock/api/demo/bigint.ts rename to apps/backend/api/demo/bigint.ts diff --git a/apps/backend/api/finance/accounts.get.ts b/apps/backend/api/finance/accounts.get.ts new file mode 100644 index 00000000..7ab1c47a --- /dev/null +++ b/apps/backend/api/finance/accounts.get.ts @@ -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); +}); diff --git a/apps/backend/api/finance/budgets.get.ts b/apps/backend/api/finance/budgets.get.ts new file mode 100644 index 00000000..a02e3a8d --- /dev/null +++ b/apps/backend/api/finance/budgets.get.ts @@ -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); +}); diff --git a/apps/backend/api/finance/budgets.post.ts b/apps/backend/api/finance/budgets.post.ts new file mode 100644 index 00000000..b943b470 --- /dev/null +++ b/apps/backend/api/finance/budgets.post.ts @@ -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); +}); diff --git a/apps/backend/api/finance/budgets/[id].delete.ts b/apps/backend/api/finance/budgets/[id].delete.ts new file mode 100644 index 00000000..6f0bf957 --- /dev/null +++ b/apps/backend/api/finance/budgets/[id].delete.ts @@ -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: '删除成功' }); +}); diff --git a/apps/backend/api/finance/budgets/[id].put.ts b/apps/backend/api/finance/budgets/[id].put.ts new file mode 100644 index 00000000..a2be11e3 --- /dev/null +++ b/apps/backend/api/finance/budgets/[id].put.ts @@ -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); +}); diff --git a/apps/backend/api/finance/categories.get.ts b/apps/backend/api/finance/categories.get.ts new file mode 100644 index 00000000..40d335c5 --- /dev/null +++ b/apps/backend/api/finance/categories.get.ts @@ -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); +}); diff --git a/apps/backend/api/finance/categories.post.ts b/apps/backend/api/finance/categories.post.ts new file mode 100644 index 00000000..d6a8f22d --- /dev/null +++ b/apps/backend/api/finance/categories.post.ts @@ -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); +}); diff --git a/apps/backend/api/finance/categories/[id].delete.ts b/apps/backend/api/finance/categories/[id].delete.ts new file mode 100644 index 00000000..7e3b82a9 --- /dev/null +++ b/apps/backend/api/finance/categories/[id].delete.ts @@ -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: '删除成功' }); +}); diff --git a/apps/backend/api/finance/categories/[id].put.ts b/apps/backend/api/finance/categories/[id].put.ts new file mode 100644 index 00000000..37f5c6c0 --- /dev/null +++ b/apps/backend/api/finance/categories/[id].put.ts @@ -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); +}); diff --git a/apps/backend/api/finance/currencies.get.ts b/apps/backend/api/finance/currencies.get.ts new file mode 100644 index 00000000..1c30f175 --- /dev/null +++ b/apps/backend/api/finance/currencies.get.ts @@ -0,0 +1,6 @@ +import { listCurrencies } from '~/utils/finance-metadata'; +import { useResponseSuccess } from '~/utils/response'; + +export default defineEventHandler(async () => { + return useResponseSuccess(listCurrencies()); +}); diff --git a/apps/backend/api/finance/exchange-rates.get.ts b/apps/backend/api/finance/exchange-rates.get.ts new file mode 100644 index 00000000..98840c4e --- /dev/null +++ b/apps/backend/api/finance/exchange-rates.get.ts @@ -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); +}); diff --git a/apps/backend/api/finance/transactions.get.ts b/apps/backend/api/finance/transactions.get.ts new file mode 100644 index 00000000..269fc2f5 --- /dev/null +++ b/apps/backend/api/finance/transactions.get.ts @@ -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); +}); diff --git a/apps/backend/api/finance/transactions.post.ts b/apps/backend/api/finance/transactions.post.ts new file mode 100644 index 00000000..4aad3532 --- /dev/null +++ b/apps/backend/api/finance/transactions.post.ts @@ -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); +}); diff --git a/apps/backend/api/finance/transactions/[id].delete.ts b/apps/backend/api/finance/transactions/[id].delete.ts new file mode 100644 index 00000000..d0121787 --- /dev/null +++ b/apps/backend/api/finance/transactions/[id].delete.ts @@ -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: '删除成功' }); +}); diff --git a/apps/backend/api/finance/transactions/[id].put.ts b/apps/backend/api/finance/transactions/[id].put.ts new file mode 100644 index 00000000..c01f1747 --- /dev/null +++ b/apps/backend/api/finance/transactions/[id].put.ts @@ -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 = {}; + + 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); +}); diff --git a/apps/backend-mock/api/menu/all.ts b/apps/backend/api/menu/all.ts similarity index 100% rename from apps/backend-mock/api/menu/all.ts rename to apps/backend/api/menu/all.ts diff --git a/apps/backend-mock/api/status.ts b/apps/backend/api/status.ts similarity index 100% rename from apps/backend-mock/api/status.ts rename to apps/backend/api/status.ts diff --git a/apps/backend-mock/api/system/dept/.post.ts b/apps/backend/api/system/dept/.post.ts similarity index 100% rename from apps/backend-mock/api/system/dept/.post.ts rename to apps/backend/api/system/dept/.post.ts diff --git a/apps/backend-mock/api/system/dept/[id].delete.ts b/apps/backend/api/system/dept/[id].delete.ts similarity index 100% rename from apps/backend-mock/api/system/dept/[id].delete.ts rename to apps/backend/api/system/dept/[id].delete.ts diff --git a/apps/backend-mock/api/system/dept/[id].put.ts b/apps/backend/api/system/dept/[id].put.ts similarity index 100% rename from apps/backend-mock/api/system/dept/[id].put.ts rename to apps/backend/api/system/dept/[id].put.ts diff --git a/apps/backend-mock/api/system/dept/list.ts b/apps/backend/api/system/dept/list.ts similarity index 100% rename from apps/backend-mock/api/system/dept/list.ts rename to apps/backend/api/system/dept/list.ts diff --git a/apps/backend-mock/api/system/menu/list.ts b/apps/backend/api/system/menu/list.ts similarity index 100% rename from apps/backend-mock/api/system/menu/list.ts rename to apps/backend/api/system/menu/list.ts diff --git a/apps/backend-mock/api/system/menu/name-exists.ts b/apps/backend/api/system/menu/name-exists.ts similarity index 100% rename from apps/backend-mock/api/system/menu/name-exists.ts rename to apps/backend/api/system/menu/name-exists.ts diff --git a/apps/backend-mock/api/system/menu/path-exists.ts b/apps/backend/api/system/menu/path-exists.ts similarity index 100% rename from apps/backend-mock/api/system/menu/path-exists.ts rename to apps/backend/api/system/menu/path-exists.ts diff --git a/apps/backend-mock/api/system/role/list.ts b/apps/backend/api/system/role/list.ts similarity index 100% rename from apps/backend-mock/api/system/role/list.ts rename to apps/backend/api/system/role/list.ts diff --git a/apps/backend-mock/api/table/list.ts b/apps/backend/api/table/list.ts similarity index 100% rename from apps/backend-mock/api/table/list.ts rename to apps/backend/api/table/list.ts diff --git a/apps/backend-mock/api/test.get.ts b/apps/backend/api/test.get.ts similarity index 100% rename from apps/backend-mock/api/test.get.ts rename to apps/backend/api/test.get.ts diff --git a/apps/backend-mock/api/test.post.ts b/apps/backend/api/test.post.ts similarity index 100% rename from apps/backend-mock/api/test.post.ts rename to apps/backend/api/test.post.ts diff --git a/apps/backend-mock/api/upload.ts b/apps/backend/api/upload.ts similarity index 100% rename from apps/backend-mock/api/upload.ts rename to apps/backend/api/upload.ts diff --git a/apps/backend-mock/api/user/info.ts b/apps/backend/api/user/info.ts similarity index 100% rename from apps/backend-mock/api/user/info.ts rename to apps/backend/api/user/info.ts diff --git a/apps/backend-mock/error.ts b/apps/backend/error.ts similarity index 100% rename from apps/backend-mock/error.ts rename to apps/backend/error.ts diff --git a/apps/backend-mock/middleware/1.api.ts b/apps/backend/middleware/1.api.ts similarity index 100% rename from apps/backend-mock/middleware/1.api.ts rename to apps/backend/middleware/1.api.ts diff --git a/apps/backend-mock/nitro.config.ts b/apps/backend/nitro.config.ts similarity index 100% rename from apps/backend-mock/nitro.config.ts rename to apps/backend/nitro.config.ts diff --git a/apps/backend-mock/package.json b/apps/backend/package.json similarity index 71% rename from apps/backend-mock/package.json rename to apps/backend/package.json index cc0b8d53..302e8052 100644 --- a/apps/backend-mock/package.json +++ b/apps/backend/package.json @@ -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:" }, diff --git a/apps/backend-mock/routes/[...].ts b/apps/backend/routes/[...].ts similarity index 100% rename from apps/backend-mock/routes/[...].ts rename to apps/backend/routes/[...].ts diff --git a/apps/backend/scripts/import-finance-data.js b/apps/backend/scripts/import-finance-data.js new file mode 100644 index 00000000..1f784e70 --- /dev/null +++ b/apps/backend/scripts/import-finance-data.js @@ -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} 个。`); diff --git a/apps/backend-mock/tsconfig.build.json b/apps/backend/tsconfig.build.json similarity index 100% rename from apps/backend-mock/tsconfig.build.json rename to apps/backend/tsconfig.build.json diff --git a/apps/backend-mock/tsconfig.json b/apps/backend/tsconfig.json similarity index 100% rename from apps/backend-mock/tsconfig.json rename to apps/backend/tsconfig.json diff --git a/apps/backend-mock/utils/cookie-utils.ts b/apps/backend/utils/cookie-utils.ts similarity index 100% rename from apps/backend-mock/utils/cookie-utils.ts rename to apps/backend/utils/cookie-utils.ts diff --git a/apps/backend/utils/finance-metadata.ts b/apps/backend/utils/finance-metadata.ts new file mode 100644 index 00000000..07ea54c4 --- /dev/null +++ b/apps/backend/utils/finance-metadata.ts @@ -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; +} diff --git a/apps/backend/utils/finance-repository.ts b/apps/backend/utils/finance-repository.ts new file mode 100644 index 00000000..67141654 --- /dev/null +++ b/apps/backend/utils/finance-repository.ts @@ -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 = {}; + + 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( + `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( + `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) => { + 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( + `SELECT id, name, type, icon, color, user_id, is_active FROM finance_categories ${where} ORDER BY id ASC`, + ); + + return stmt.all(params).map(mapCategory); +} diff --git a/apps/backend-mock/utils/jwt-utils.ts b/apps/backend/utils/jwt-utils.ts similarity index 100% rename from apps/backend-mock/utils/jwt-utils.ts rename to apps/backend/utils/jwt-utils.ts diff --git a/apps/backend/utils/mock-data.ts b/apps/backend/utils/mock-data.ts new file mode 100644 index 00000000..733cba30 --- /dev/null +++ b/apps/backend/utils/mock-data.ts @@ -0,0 +1,1105 @@ +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: '/workspace', + }, +]; + +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 = [ + { + name: 'FinanceDashboard', + path: '/finance/dashboard', + component: '/finance/dashboard/index', + meta: { + order: 1, + title: '📊 财务仪表板', + icon: 'mdi:chart-box', + }, + }, + { + name: 'FinanceTransactions', + path: '/finance/transactions', + component: '/finance/transactions/index', + meta: { + order: 2, + title: '💰 交易管理', + icon: 'mdi:swap-horizontal', + }, + }, + { + name: 'FinanceAccounts', + path: '/finance/accounts', + component: '/finance/accounts/index', + meta: { + order: 3, + title: '🏦 账户管理', + icon: 'mdi:account-multiple', + }, + }, + { + name: 'FinanceCategories', + path: '/finance/categories', + component: '/finance/categories/index', + meta: { + order: 4, + title: '🏷️ 分类管理', + icon: 'mdi:tag-multiple', + }, + }, + { + name: 'FinanceBudgets', + path: '/finance/budgets', + component: '/finance/budgets/index', + meta: { + order: 5, + title: '🎯 预算管理', + icon: 'mdi:target', + }, + }, + { + name: 'ReportsAnalytics', + path: '/finance/reports', + component: '/finance/reports/index', + meta: { + order: 6, + title: '📈 报表分析', + icon: 'mdi:chart-line', + }, + }, + { + name: 'FinanceTools', + path: '/finance/tools', + component: '/finance/tools/index', + meta: { + order: 7, + title: '🛠️ 财务工具', + icon: 'mdi:tools', + }, + }, + { + name: 'FinanceSettings', + path: '/finance/settings', + component: '/finance/settings/index', + meta: { + order: 8, + title: '⚙️ 系统设置', + icon: 'mdi:cog', + }, + }, +]; + +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; +} + +// ==================== 财务管理数据 ==================== + +// 货币类型 +export interface Currency { + code: string; + name: string; + symbol: string; + isBase: boolean; + isActive: boolean; +} + +export const MOCK_CURRENCIES: Currency[] = [ + { + code: 'CNY', + name: '人民币', + symbol: '¥', + isBase: true, + isActive: true, + }, + { + code: 'THB', + name: '泰铢', + symbol: '฿', + isBase: false, + isActive: true, + }, + { + code: 'USD', + name: '美元', + symbol: '$', + isBase: false, + isActive: true, + }, +]; + +// 汇率历史记录 +export interface ExchangeRate { + id: number; + fromCurrency: string; + toCurrency: string; + rate: number; + date: string; + source: 'manual' | 'api' | 'system'; +} + +export const MOCK_EXCHANGE_RATES: ExchangeRate[] = [ + // CNY 作为基准货币 + { id: 1, fromCurrency: 'CNY', toCurrency: 'CNY', rate: 1.0, date: '2025-10-03', source: 'system' }, + { id: 2, fromCurrency: 'CNY', toCurrency: 'THB', rate: 5.0, date: '2025-10-03', source: 'api' }, + { id: 3, fromCurrency: 'CNY', toCurrency: 'USD', rate: 0.14, date: '2025-10-03', source: 'api' }, + + // THB 换算 + { id: 4, fromCurrency: 'THB', toCurrency: 'CNY', rate: 0.2, date: '2025-10-03', source: 'api' }, + { id: 5, fromCurrency: 'THB', toCurrency: 'THB', rate: 1.0, date: '2025-10-03', source: 'system' }, + { id: 6, fromCurrency: 'THB', toCurrency: 'USD', rate: 0.028, date: '2025-10-03', source: 'api' }, + + // USD 换算 + { id: 7, fromCurrency: 'USD', toCurrency: 'CNY', rate: 7.14, date: '2025-10-03', source: 'api' }, + { id: 8, fromCurrency: 'USD', toCurrency: 'THB', rate: 35.7, date: '2025-10-03', source: 'api' }, + { id: 9, fromCurrency: 'USD', toCurrency: 'USD', rate: 1.0, date: '2025-10-03', source: 'system' }, +]; + +// 分类 +export interface Category { + id: number; + userId: number | null; // null 表示系统预设 + name: string; + type: 'income' | 'expense'; + icon: string; + color: string; + sortOrder: number; + isSystem: boolean; + isActive: boolean; +} + +export const MOCK_CATEGORIES: Category[] = [ + // 支出分类 + { id: 1, userId: null, name: '餐饮', type: 'expense', icon: '🍜', color: '#ff6b6b', sortOrder: 1, isSystem: true, isActive: true }, + { id: 2, userId: null, name: '交通', type: 'expense', icon: '🚗', color: '#4ecdc4', sortOrder: 2, isSystem: true, isActive: true }, + { id: 3, userId: null, name: '购物', type: 'expense', icon: '🛍️', color: '#95e1d3', sortOrder: 3, isSystem: true, isActive: true }, + { id: 4, userId: null, name: '娱乐', type: 'expense', icon: '🎮', color: '#f38181', sortOrder: 4, isSystem: true, isActive: true }, + { id: 5, userId: null, name: '软件订阅', type: 'expense', icon: '💻', color: '#aa96da', sortOrder: 5, isSystem: true, isActive: true }, + { id: 6, userId: null, name: '投资支出', type: 'expense', icon: '📊', color: '#fcbad3', sortOrder: 6, isSystem: true, isActive: true }, + { id: 7, userId: null, name: '医疗健康', type: 'expense', icon: '🏥', color: '#a8d8ea', sortOrder: 7, isSystem: true, isActive: true }, + { id: 8, userId: null, name: '房租房贷', type: 'expense', icon: '🏠', color: '#ffcccc', sortOrder: 8, isSystem: true, isActive: true }, + { id: 9, userId: null, name: '教育', type: 'expense', icon: '📚', color: '#ffd3b6', sortOrder: 9, isSystem: true, isActive: true }, + { id: 10, userId: null, name: '其他支出', type: 'expense', icon: '📝', color: '#dfe4ea', sortOrder: 99, isSystem: true, isActive: true }, + + // 收入分类 + { id: 11, userId: null, name: '工资', type: 'income', icon: '💼', color: '#38ada9', sortOrder: 1, isSystem: true, isActive: true }, + { id: 12, userId: null, name: '奖金', type: 'income', icon: '🎁', color: '#78e08f', sortOrder: 2, isSystem: true, isActive: true }, + { id: 13, userId: null, name: '投资收益', type: 'income', icon: '📈', color: '#079992', sortOrder: 3, isSystem: true, isActive: true }, + { id: 14, userId: null, name: '副业收入', type: 'income', icon: '💡', color: '#60a3bc', sortOrder: 4, isSystem: true, isActive: true }, + { id: 15, userId: null, name: '其他收入', type: 'income', icon: '💰', color: '#82ccdd', sortOrder: 99, isSystem: true, isActive: true }, +]; + +// 账户 +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 const MOCK_ACCOUNTS: Account[] = [ + // CNY 账户 + { id: 1, userId: 1, name: '支付宝', type: 'alipay', currency: 'CNY', balance: 5280.50, icon: '💙', color: '#1677ff', isActive: true }, + { id: 2, userId: 1, name: '微信钱包', type: 'wechat', currency: 'CNY', balance: 1520.30, icon: '💚', color: '#07c160', isActive: true }, + { id: 3, userId: 1, name: '中国银行', type: 'bank', currency: 'CNY', balance: 12500.00, icon: '🏦', color: '#c41e3a', isActive: true }, + { id: 4, userId: 1, name: '人民币现金', type: 'cash', currency: 'CNY', balance: 800.00, icon: '💵', color: '#52c41a', isActive: true }, + + // THB 账户 + { id: 5, userId: 1, name: '泰铢现金', type: 'cash', currency: 'THB', balance: 15000.00, icon: '💵', color: '#faad14', isActive: true }, + { id: 6, userId: 1, name: '泰国银行', type: 'bank', currency: 'THB', balance: 48000.00, icon: '🏦', color: '#722ed1', isActive: true }, + + // USD 账户 + { id: 7, userId: 1, name: '美金现金', type: 'cash', currency: 'USD', balance: 500.00, icon: '💵', color: '#13c2c2', isActive: true }, + { id: 8, userId: 1, name: 'PayPal', type: 'bank', currency: 'USD', balance: 1250.00, icon: '💳', color: '#0070ba', isActive: true }, + + // 虚拟钱包 + { id: 9, userId: 1, name: 'USDT钱包', type: 'virtual_wallet', currency: 'USD', balance: 3000.00, icon: '💎', color: '#26a17b', isActive: true }, + { id: 10, userId: 1, name: 'BTC钱包', type: 'virtual_wallet', currency: 'USD', balance: 0.05, icon: '₿', color: '#f7931a', isActive: true }, + + // 投资账户 + { id: 11, userId: 1, name: '证券账户', type: 'investment', currency: 'CNY', balance: 25000.00, icon: '📊', color: '#eb2f96', isActive: true }, + + // 信用卡 + { id: 12, userId: 1, name: '招商银行信用卡', type: 'credit_card', currency: 'CNY', balance: -3500.00, icon: '💳', color: '#f5222d', isActive: true }, +]; + +// 交易记录 +export interface Transaction { + id: number; + userId: number; + type: 'income' | 'expense' | 'transfer'; + amount: number; + currency: string; + exchangeRateToBase: number; + amountInBase: number; + categoryId: number | null; + accountId: number; + transactionDate: string; + description: string; + createdAt: string; + isDeleted?: boolean; + deletedAt?: string; +} + +interface TransactionSeed { + type: Transaction['type']; + amount: number; + currency: string; + categoryId: number | null; + accountId: number; + transactionDate: string; + description: string; +} + +function getExchangeRateToBase(currency: string) { + const rate = MOCK_EXCHANGE_RATES.find( + (item) => item.fromCurrency === currency && item.toCurrency === 'CNY', + ); + return rate?.rate ?? 1; +} + +function normalizeAmount(value: number) { + return Number(value.toFixed(2)); +} + +const TRANSACTION_SEEDS: TransactionSeed[] = [ + { + type: 'income', + amount: 12800, + currency: 'CNY', + categoryId: 11, + accountId: 3, + transactionDate: '2025-10-08', + description: '十月工资入账', + }, + { + type: 'income', + amount: 2800, + currency: 'CNY', + categoryId: 12, + accountId: 3, + transactionDate: '2025-10-18', + description: '季度绩效奖金', + }, + { + type: 'income', + amount: 460, + currency: 'USD', + categoryId: 13, + accountId: 9, + transactionDate: '2025-10-21', + description: '股票分红(USD)', + }, + { + type: 'expense', + amount: 3850, + currency: 'CNY', + categoryId: 8, + accountId: 3, + transactionDate: '2025-10-05', + description: '十月房租支出', + }, + { + type: 'expense', + amount: 248.4, + currency: 'CNY', + categoryId: 1, + accountId: 1, + transactionDate: '2025-10-07', + description: '家庭聚餐消费', + }, + { + type: 'expense', + amount: 612.5, + currency: 'CNY', + categoryId: 3, + accountId: 2, + transactionDate: '2025-10-11', + description: '大型超市购物', + }, + { + type: 'expense', + amount: 420, + currency: 'CNY', + categoryId: 4, + accountId: 2, + transactionDate: '2025-10-14', + description: '娱乐活动(电影+KTV)', + }, + { + type: 'expense', + amount: 1350, + currency: 'CNY', + categoryId: 7, + accountId: 3, + transactionDate: '2025-10-19', + description: '体检及医疗支出', + }, + { + type: 'expense', + amount: 92.6, + currency: 'CNY', + categoryId: 2, + accountId: 1, + transactionDate: '2025-10-22', + description: '共享单车与地铁', + }, + { + type: 'expense', + amount: 168, + currency: 'CNY', + categoryId: 5, + accountId: 4, + transactionDate: '2025-10-25', + description: '云服务与软件订阅', + }, + { + type: 'income', + amount: 4500, + currency: 'USD', + categoryId: 14, + accountId: 9, + transactionDate: '2025-09-10', + description: '驻外项目服务费', + }, + { + type: 'income', + amount: 12650, + currency: 'CNY', + categoryId: 11, + accountId: 3, + transactionDate: '2025-09-08', + description: '九月工资入账', + }, + { + type: 'expense', + amount: 3720, + currency: 'CNY', + categoryId: 8, + accountId: 3, + transactionDate: '2025-09-05', + description: '九月房租支出', + }, + { + type: 'expense', + amount: 520.8, + currency: 'CNY', + categoryId: 1, + accountId: 1, + transactionDate: '2025-09-09', + description: '中秋家庭聚餐', + }, + { + type: 'expense', + amount: 980, + currency: 'CNY', + categoryId: 6, + accountId: 11, + transactionDate: '2025-09-15', + description: '指数基金定投', + }, + { + type: 'expense', + amount: 312, + currency: 'CNY', + categoryId: 3, + accountId: 2, + transactionDate: '2025-09-18', + description: '电商平台日常用品', + }, + { + type: 'expense', + amount: 1500, + currency: 'CNY', + categoryId: 9, + accountId: 3, + transactionDate: '2025-09-20', + description: '孩子辅导课程', + }, + { + type: 'expense', + amount: 108.6, + currency: 'CNY', + categoryId: 2, + accountId: 2, + transactionDate: '2025-09-22', + description: '地铁月度充值', + }, + { + type: 'expense', + amount: 65, + currency: 'THB', + categoryId: 1, + accountId: 5, + transactionDate: '2025-09-26', + description: '曼谷街头小吃', + }, + { + type: 'expense', + amount: 210, + currency: 'USD', + categoryId: 5, + accountId: 8, + transactionDate: '2025-09-28', + description: '年度生产力工具订阅', + }, + { + type: 'income', + amount: 12580, + currency: 'CNY', + categoryId: 11, + accountId: 3, + transactionDate: '2025-08-08', + description: '八月工资入账', + }, + { + type: 'income', + amount: 2150, + currency: 'CNY', + categoryId: 13, + accountId: 11, + transactionDate: '2025-08-16', + description: '理财产品收益', + }, + { + type: 'income', + amount: 320, + currency: 'USD', + categoryId: 15, + accountId: 9, + transactionDate: '2025-08-24', + description: '海外二手交易收入', + }, + { + type: 'expense', + amount: 3680, + currency: 'CNY', + categoryId: 8, + accountId: 3, + transactionDate: '2025-08-05', + description: '八月房租支出', + }, + { + type: 'expense', + amount: 452.3, + currency: 'CNY', + categoryId: 1, + accountId: 1, + transactionDate: '2025-08-07', + description: '工作日餐饮', + }, + { + type: 'expense', + amount: 275.4, + currency: 'CNY', + categoryId: 4, + accountId: 2, + transactionDate: '2025-08-12', + description: '家庭周末娱乐', + }, + { + type: 'expense', + amount: 860, + currency: 'CNY', + categoryId: 6, + accountId: 11, + transactionDate: '2025-08-15', + description: '基金定投计划', + }, + { + type: 'expense', + amount: 1999, + currency: 'CNY', + categoryId: 3, + accountId: 3, + transactionDate: '2025-08-18', + description: '家用电器采购', + }, + { + type: 'expense', + amount: 145, + currency: 'CNY', + categoryId: 2, + accountId: 2, + transactionDate: '2025-08-20', + description: '外出交通打车', + }, + { + type: 'expense', + amount: 72, + currency: 'USD', + categoryId: 5, + accountId: 8, + transactionDate: '2025-08-23', + description: '云服务增值包', + }, + { + type: 'income', + amount: 12480, + currency: 'CNY', + categoryId: 11, + accountId: 3, + transactionDate: '2025-07-08', + description: '七月工资入账', + }, + { + type: 'expense', + amount: 3680, + currency: 'CNY', + categoryId: 8, + accountId: 3, + transactionDate: '2025-07-05', + description: '七月房租支出', + }, + { + type: 'expense', + amount: 1299, + currency: 'CNY', + categoryId: 3, + accountId: 1, + transactionDate: '2025-07-12', + description: '暑期家庭购物', + }, + { + type: 'expense', + amount: 420, + currency: 'CNY', + categoryId: 4, + accountId: 2, + transactionDate: '2025-07-18', + description: '亲子乐园娱乐', + }, + { + type: 'expense', + amount: 960, + currency: 'CNY', + categoryId: 9, + accountId: 3, + transactionDate: '2025-07-22', + description: '暑期培训课程', + }, + { + type: 'income', + amount: 1800, + currency: 'CNY', + categoryId: 14, + accountId: 2, + transactionDate: '2025-07-25', + description: '副业项目结算', + }, + { + type: 'expense', + amount: 288, + currency: 'THB', + categoryId: 1, + accountId: 5, + transactionDate: '2025-07-27', + description: '泰国商务餐饮', + }, + { + type: 'income', + amount: 520, + currency: 'USD', + categoryId: 13, + accountId: 9, + transactionDate: '2025-07-30', + description: '海外理财收益', + }, +]; + +export const MOCK_TRANSACTIONS: Transaction[] = TRANSACTION_SEEDS.map((seed, index) => { + const exchangeRate = getExchangeRateToBase(seed.currency); + const amountInBase = normalizeAmount(seed.amount * exchangeRate); + + return { + id: index + 1, + userId: 1, + type: seed.type, + amount: normalizeAmount(seed.amount), + currency: seed.currency, + exchangeRateToBase: normalizeAmount(exchangeRate), + amountInBase, + categoryId: seed.categoryId, + accountId: seed.accountId, + transactionDate: seed.transactionDate, + description: seed.description, + createdAt: `${seed.transactionDate}T09:00:00.000Z`, + }; +}); + +// 预算管理 +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 const MOCK_BUDGETS: Budget[] = []; diff --git a/apps/backend-mock/utils/response.ts b/apps/backend/utils/response.ts similarity index 100% rename from apps/backend-mock/utils/response.ts rename to apps/backend/utils/response.ts diff --git a/apps/backend/utils/sqlite.ts b/apps/backend/utils/sqlite.ts new file mode 100644 index 00000000..b9cc9236 --- /dev/null +++ b/apps/backend/utils/sqlite.ts @@ -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; diff --git a/apps/web-antd/index.html b/apps/web-antd/index.html index 480eb84d..9b94619f 100644 --- a/apps/web-antd/index.html +++ b/apps/web-antd/index.html @@ -30,6 +30,96 @@
+ diff --git a/apps/web-antd/package.json b/apps/web-antd/package.json index 5b6cbeb3..27e09b5a 100644 --- a/apps/web-antd/package.json +++ b/apps/web-antd/package.json @@ -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" } } diff --git a/apps/web-antd/scripts/add-category-to-csv.ts b/apps/web-antd/scripts/add-category-to-csv.ts new file mode 100644 index 00000000..8ab3706e --- /dev/null +++ b/apps/web-antd/scripts/add-category-to-csv.ts @@ -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} 条记录`); diff --git a/apps/web-antd/scripts/import-csv.ts b/apps/web-antd/scripts/import-csv.ts new file mode 100644 index 00000000..62e115a7 --- /dev/null +++ b/apps/web-antd/scripts/import-csv.ts @@ -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 = { + '工资': 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); diff --git a/apps/web-antd/src/api/core/finance.ts b/apps/web-antd/src/api/core/finance.ts new file mode 100644 index 00000000..77c3ee1d --- /dev/null +++ b/apps/web-antd/src/api/core/finance.ts @@ -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('/finance/currencies'); + } + + /** + * 获取分类 + */ + export async function getCategories(params?: { type?: 'income' | 'expense' | 'transfer' }) { + return requestClient.get('/finance/categories', { params }); + } + + /** + * 创建分类 + */ + export async function createCategory(data: { + name: string; + type: 'income' | 'expense'; + icon?: string; + color?: string; + }) { + return requestClient.post('/finance/categories', data); + } + + /** + * 更新分类 + */ + export async function updateCategory( + id: number, + data: { + name?: string; + icon?: string; + color?: string; + sortOrder?: number; + }, + ) { + return requestClient.put(`/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('/finance/accounts', { params }); + } + + /** + * 获取汇率 + */ + export async function getExchangeRates(params?: { + from?: string; + to?: string; + date?: string; + }) { + return requestClient.get('/finance/exchange-rates', { + params, + }); + } + + /** + * 获取交易列表 + */ + export async function getTransactions(params?: { + type?: 'income' | 'expense' | 'transfer'; + }) { + return requestClient.get('/finance/transactions', { + params, + }); + } + + /** + * 创建交易 + */ + export async function createTransaction(data: CreateTransactionParams) { + return requestClient.post('/finance/transactions', data); + } + + /** + * 更新交易 + */ + export async function updateTransaction( + id: number, + data: Partial, + ) { + return requestClient.put(`/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(`/finance/transactions/${id}`, { + isDeleted: false, + }); + } + + /** + * 获取预算列表 + */ + export async function getBudgets() { + return requestClient.get('/finance/budgets'); + } + + /** + * 创建预算 + */ + export async function createBudget(data: CreateBudgetParams) { + return requestClient.post('/finance/budgets', data); + } + + /** + * 更新预算 + */ + export async function updateBudget( + id: number, + data: Partial, + ) { + return requestClient.put(`/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(`/finance/budgets/${id}`, { + isDeleted: false, + }); + } +} diff --git a/apps/web-antd/src/api/request.ts b/apps/web-antd/src/api/request.ts index 288dddd0..52f5c6da 100644 --- a/apps/web-antd/src/api/request.ts +++ b/apps/web-antd/src/api/request.ts @@ -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 ?? ''; // 如果没有错误信息,则会根据状态码进行提示 diff --git a/apps/web-antd/src/app.vue b/apps/web-antd/src/app.vue index bbaccce1..7dfb2a2f 100644 --- a/apps/web-antd/src/app.vue +++ b/apps/web-antd/src/app.vue @@ -1,5 +1,5 @@ + + diff --git a/apps/web-antd/src/custom.css b/apps/web-antd/src/custom.css new file mode 100644 index 00000000..4bb156b0 --- /dev/null +++ b/apps/web-antd/src/custom.css @@ -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; +} diff --git a/apps/web-antd/src/main.ts b/apps/web-antd/src/main.ts index 5d728a02..6c5cd801 100644 --- a/apps/web-antd/src/main.ts +++ b/apps/web-antd/src/main.ts @@ -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); diff --git a/apps/web-antd/src/preferences.ts b/apps/web-antd/src/preferences.ts index 2b0dc4f6..f38d95d3 100644 --- a/apps/web-antd/src/preferences.ts +++ b/apps/web-antd/src/preferences.ts @@ -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, // 禁用折叠按钮 }, }); diff --git a/apps/web-antd/src/router/index.ts b/apps/web-antd/src/router/index.ts index 48402303..5d391f6c 100644 --- a/apps/web-antd/src/router/index.ts +++ b/apps/web-antd/src/router/index.ts @@ -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 }; diff --git a/apps/web-antd/src/router/routes/modules/business-modules.ts b/apps/web-antd/src/router/routes/modules/business-modules.ts new file mode 100644 index 00000000..a8651e57 --- /dev/null +++ b/apps/web-antd/src/router/routes/modules/business-modules.ts @@ -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; diff --git a/apps/web-antd/src/router/routes/modules/dashboard.ts b/apps/web-antd/src/router/routes/modules/dashboard.ts index 5254dc65..701fa59b 100644 --- a/apps/web-antd/src/router/routes/modules/dashboard.ts +++ b/apps/web-antd/src/router/routes/modules/dashboard.ts @@ -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', }, ]; diff --git a/apps/web-antd/src/router/routes/modules/finance-system.ts b/apps/web-antd/src/router/routes/modules/finance-system.ts deleted file mode 100644 index 5b1de56a..00000000 --- a/apps/web-antd/src/router/routes/modules/finance-system.ts +++ /dev/null @@ -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; \ No newline at end of file diff --git a/apps/web-antd/src/store/finance.ts b/apps/web-antd/src/store/finance.ts new file mode 100644 index 00000000..f3b00aed --- /dev/null +++ b/apps/web-antd/src/store/finance.ts @@ -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([]); + const incomeCategories = ref([]); + const expenseCategories = ref([]); + const accounts = ref([]); + const exchangeRates = ref([]); + const transactions = ref([]); + const budgets = ref([]); + + // 加载状态 + 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, + ) { + 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, + ) { + 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, + }; +}); diff --git a/apps/web-antd/src/views/_core/authentication/login.vue b/apps/web-antd/src/views/_core/authentication/login.vue index 099e4c8c..3c03f903 100644 --- a/apps/web-antd/src/views/_core/authentication/login.vue +++ b/apps/web-antd/src/views/_core/authentication/login.vue @@ -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'), + // }), + // }, ]; }); diff --git a/apps/web-antd/src/views/_core/fallback/not-found.vue b/apps/web-antd/src/views/_core/fallback/not-found.vue index 4d178e9c..205dca03 100644 --- a/apps/web-antd/src/views/_core/fallback/not-found.vue +++ b/apps/web-antd/src/views/_core/fallback/not-found.vue @@ -1,9 +1,25 @@ diff --git a/apps/web-antd/src/views/dashboard/workspace/index.vue b/apps/web-antd/src/views/dashboard/workspace/index.vue index b95d6138..eadc41d7 100644 --- a/apps/web-antd/src/views/dashboard/workspace/index.vue +++ b/apps/web-antd/src/views/dashboard/workspace/index.vue @@ -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([ { 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: `在 开源组 创建了项目 Vue`, + content: `添加了一笔 餐饮支出 ¥128.50`, date: '刚刚', - title: '威廉', + title: '系统记录', }, { avatar: 'svg:avatar-2', - content: `关注了 威廉 `, - date: '1个小时前', - title: '艾文', + content: `记录了 工资收入 ¥12,000.00`, + date: '2小时前', + title: '收入记录', }, { avatar: 'svg:avatar-3', - content: `发布了 个人动态 `, - date: '1天前', - title: '克里斯', + content: `更新了 餐饮类别 的预算额度`, + date: '今天 14:30', + title: '预算调整', }, { avatar: 'svg:avatar-4', - content: `发表文章 如何编写一个Vite插件 `, - date: '2天前', - title: 'Vben', + content: `创建了新的 信用卡账户 `, + date: '今天 10:15', + title: '账户管理', }, { avatar: 'svg:avatar-1', - content: `回复了 杰克 的问题 如何进行项目优化?`, - date: '3天前', - title: '皮特', + content: `生成了 月度财务报表`, + date: '昨天', + title: '报表生成', }, { avatar: 'svg:avatar-2', - content: `关闭了问题 如何运行项目 `, - date: '1周前', - title: '杰克', + content: `完成了 账户对账 操作`, + date: '昨天', + title: '对账记录', }, { avatar: 'svg:avatar-3', - content: `发布了 个人动态 `, + content: `添加了 房租支出 ¥3,500.00`, + date: '2天前', + title: '支出记录', + }, + { + avatar: 'svg:avatar-4', + content: `设置了 月度预算目标`, + date: '3天前', + title: '预算规划', + }, + { + avatar: 'svg:avatar-1', + content: `优化了 支出分类 设置`, date: '1周前', - title: '威廉', - }, - { - avatar: 'svg:avatar-4', - content: `推送了代码到 Github`, - date: '2021-04-01 20:00', - title: '威廉', - }, - { - avatar: 'svg:avatar-4', - content: `发表文章 如何编写使用 Admin Vben `, - 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" > + -
- - + +
- - + +
+ + + +
+ + + + +
+ + + {{ category.icon }} {{ category.name }} + + +
+
+ + + + + + + + +
+ + +
+ + +
+ + + + {{ currency.symbol }} {{ currency.name }} + + +
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + + +
+ + + +
+ +
+ + + + + +
+ + + +
+ +
+ + + + + + + + + + + + + +
+ +
+ +
+
+
+ + + + + +
+ + + + +
+
+ + + +
+ +
+
+ +
+
+
+ + diff --git a/apps/web-antd/src/views/finance/accounts/index.vue b/apps/web-antd/src/views/finance/accounts/index.vue index 3b8b4e87..3ffe38f9 100644 --- a/apps/web-antd/src/views/finance/accounts/index.vue +++ b/apps/web-antd/src/views/finance/accounts/index.vue @@ -51,7 +51,7 @@ @@ -66,17 +66,16 @@ - +

- {{ account.balance.toLocaleString() }} {{ account.currency || 'CNY' }} + {{ getCurrencySymbol(account.currency) }}{{ account.balance.toLocaleString() }}

-

{{ account.type }}

-

{{ account.bank }}

-

{{ account.currency }}

+

{{ getAccountTypeText(account.type) }}

+

{{ account.currency }}

- +
@@ -85,10 +84,10 @@
- - + + + + +
+ + + + + + + + + + + + + + + +
+
+ + + +
+
📊
+

暂无交易记录

+

该账户还没有任何交易记录

+
+ + +
+
diff --git a/apps/web-antd/src/views/finance/budgets/index.vue b/apps/web-antd/src/views/finance/budgets/index.vue index fe615eb5..be617196 100644 --- a/apps/web-antd/src/views/finance/budgets/index.vue +++ b/apps/web-antd/src/views/finance/budgets/index.vue @@ -265,15 +265,18 @@ \ No newline at end of file + diff --git a/apps/web-antd/src/views/finance/reports/index.vue b/apps/web-antd/src/views/finance/reports/index.vue index f2c52c68..3437768b 100644 --- a/apps/web-antd/src/views/finance/reports/index.vue +++ b/apps/web-antd/src/views/finance/reports/index.vue @@ -1,37 +1,788 @@ \ No newline at end of file + diff --git a/apps/web-antd/src/views/finance/settings/index.vue b/apps/web-antd/src/views/finance/settings/index.vue index b75c07c4..97add301 100644 --- a/apps/web-antd/src/views/finance/settings/index.vue +++ b/apps/web-antd/src/views/finance/settings/index.vue @@ -8,17 +8,6 @@
- - - - - 通知设置
diff --git a/apps/web-antd/src/views/finance/statistics/index.vue b/apps/web-antd/src/views/finance/statistics/index.vue new file mode 100644 index 00000000..86fba739 --- /dev/null +++ b/apps/web-antd/src/views/finance/statistics/index.vue @@ -0,0 +1,1038 @@ + + + + + diff --git a/apps/web-antd/src/views/finance/transactions/index.vue b/apps/web-antd/src/views/finance/transactions/index.vue index 2c94fada..ba3dea77 100644 --- a/apps/web-antd/src/views/finance/transactions/index.vue +++ b/apps/web-antd/src/views/finance/transactions/index.vue @@ -1,38 +1,1343 @@ + +