diff --git a/apps/web-finance/.env b/apps/web-finance/.env new file mode 100644 index 00000000..19735f36 --- /dev/null +++ b/apps/web-finance/.env @@ -0,0 +1,8 @@ +# 应用标题 +VITE_APP_TITLE=Vben Admin Antd + +# 应用命名空间,用于缓存、store等功能的前缀,确保隔离 +VITE_APP_NAMESPACE=vben-web-antd + +# 对store进行加密的密钥,在将store持久化到localStorage时会使用该密钥进行加密 +VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key diff --git a/apps/web-finance/.env.analyze b/apps/web-finance/.env.analyze new file mode 100644 index 00000000..ffafa8dd --- /dev/null +++ b/apps/web-finance/.env.analyze @@ -0,0 +1,7 @@ +# public path +VITE_BASE=/ + +# Basic interface address SPA +VITE_GLOB_API_URL=/api + +VITE_VISUALIZER=true diff --git a/apps/web-finance/.env.development b/apps/web-finance/.env.development new file mode 100644 index 00000000..c138f482 --- /dev/null +++ b/apps/web-finance/.env.development @@ -0,0 +1,16 @@ +# 端口号 +VITE_PORT=5666 + +VITE_BASE=/ + +# 接口地址 +VITE_GLOB_API_URL=/api + +# 是否开启 Nitro Mock服务,true 为开启,false 为关闭 +VITE_NITRO_MOCK=true + +# 是否打开 devtools,true 为打开,false 为关闭 +VITE_DEVTOOLS=false + +# 是否注入全局loading +VITE_INJECT_APP_LOADING=true diff --git a/apps/web-finance/.env.production b/apps/web-finance/.env.production new file mode 100644 index 00000000..5375847a --- /dev/null +++ b/apps/web-finance/.env.production @@ -0,0 +1,19 @@ +VITE_BASE=/ + +# 接口地址 +VITE_GLOB_API_URL=https://mock-napi.vben.pro/api + +# 是否开启压缩,可以设置为 none, brotli, gzip +VITE_COMPRESS=none + +# 是否开启 PWA +VITE_PWA=false + +# vue-router 的模式 +VITE_ROUTER_HISTORY=hash + +# 是否注入全局loading +VITE_INJECT_APP_LOADING=true + +# 打包后是否生成dist.zip +VITE_ARCHIVER=true diff --git a/apps/web-finance/README.md b/apps/web-finance/README.md new file mode 100644 index 00000000..99e4d8c6 --- /dev/null +++ b/apps/web-finance/README.md @@ -0,0 +1,105 @@ +# TokenRecords 财务管理系统 (VbenAdmin 版本) + +基于 VbenAdmin 框架构建的现代化财务管理系统,提供完整的收支记录、分类管理、人员管理和贷款管理功能。 + +## 功能特性 + +### 核心功能 +- **交易管理**:记录和管理所有收支交易,支持多币种、多状态管理 +- **分类管理**:灵活的收支分类体系,支持自定义分类 +- **人员管理**:管理交易相关人员,支持多角色(付款人、收款人、借款人、出借人) +- **贷款管理**:完整的贷款和还款记录管理,自动计算还款进度 + +### 技术特性 +- **现代化技术栈**:Vue 3 + TypeScript + Vite + Pinia + Ant Design Vue +- **本地存储**:使用 IndexedDB 进行数据持久化,支持离线使用 +- **Mock API**:完整的 Mock 数据服务,方便开发和测试 +- **响应式设计**:适配各种屏幕尺寸 +- **国际化支持**:内置中文语言包,可扩展多语言 + +## 快速开始 + +### 安装依赖 +```bash +pnpm install +``` + +### 启动开发服务器 +```bash +pnpm dev:finance +``` + +### 访问系统 +- 开发地址:http://localhost:5666/ +- 默认账号:vben +- 默认密码:123456 + +## 项目结构 + +``` +src/ +├── api/ # API 接口 +│ ├── finance/ # 财务相关 API +│ └── mock/ # Mock 数据服务 +├── store/ # 状态管理 +│ └── modules/ # 业务模块 +├── types/ # TypeScript 类型定义 +├── utils/ # 工具函数 +│ ├── db.ts # IndexedDB 工具 +│ └── data-migration.ts # 数据迁移工具 +├── views/ # 页面组件 +│ ├── finance/ # 财务管理页面 +│ ├── analytics/ # 统计分析页面 +│ └── tools/ # 系统工具页面 +├── router/ # 路由配置 +└── locales/ # 国际化配置 +``` + +## 数据存储 + +系统使用 IndexedDB 作为本地存储方案,支持: +- 自动数据持久化 +- 事务支持 +- 索引查询 +- 数据备份和恢复 + +### 数据迁移 +如果您有旧版本的数据(存储在 localStorage),系统会在启动时自动检测并迁移到新的存储系统。 + +## 开发指南 + +### 添加新功能 +1. 在 `types/finance.ts` 中定义数据类型 +2. 在 `api/finance/` 中创建 API 接口 +3. 在 `store/modules/` 中创建状态管理 +4. 在 `views/` 中创建页面组件 +5. 在 `router/routes/modules/` 中配置路由 + +### Mock 数据 +Mock 数据服务位于 `api/mock/finance-service.ts`,可以根据需要修改初始数据或添加新的 Mock 接口。 + +## 测试 + +运行 Playwright 测试: +```bash +node test-finance-system.js +``` + +## 部署 + +### 构建生产版本 +```bash +pnpm build:finance +``` + +构建产物将生成在 `dist` 目录中。 + +## 技术支持 + +- VbenAdmin 文档:https://doc.vben.pro/ +- Vue 3 文档:https://cn.vuejs.org/ +- Ant Design Vue:https://antdv.com/ + +## 许可证 + +MIT \ No newline at end of file diff --git a/apps/web-finance/analytics-error-simple.png b/apps/web-finance/analytics-error-simple.png new file mode 100644 index 00000000..e1adc471 Binary files /dev/null and b/apps/web-finance/analytics-error-simple.png differ diff --git a/apps/web-finance/analytics-error.png b/apps/web-finance/analytics-error.png new file mode 100644 index 00000000..8cad6f22 Binary files /dev/null and b/apps/web-finance/analytics-error.png differ diff --git a/apps/web-finance/analytics-page-state.png b/apps/web-finance/analytics-page-state.png new file mode 100644 index 00000000..6de32a04 Binary files /dev/null and b/apps/web-finance/analytics-page-state.png differ diff --git a/apps/web-finance/check-server.js b/apps/web-finance/check-server.js new file mode 100644 index 00000000..c0b12712 --- /dev/null +++ b/apps/web-finance/check-server.js @@ -0,0 +1,65 @@ +import { chromium } from 'playwright'; + +(async () => { + const browser = await chromium.launch({ + headless: false // 有头模式,方便观察 + }); + const context = await browser.newContext(); + const page = await context.newPage(); + + // 监听控制台消息 + page.on('console', msg => { + console.log(`浏览器控制台 [${msg.type()}]:`, msg.text()); + }); + + // 监听页面错误 + page.on('pageerror', error => { + console.error('页面错误:', error.message); + }); + + try { + console.log('正在访问 http://localhost:5666/ ...\n'); + + const response = await page.goto('http://localhost:5666/', { + waitUntil: 'domcontentloaded', + timeout: 30000 + }); + + console.log('响应状态:', response?.status()); + console.log('当前URL:', page.url()); + + // 等待页面加载 + await page.waitForTimeout(3000); + + // 截图查看页面状态 + await page.screenshot({ + path: 'server-check.png', + fullPage: true + }); + console.log('\n已保存截图: server-check.png'); + + // 检查页面内容 + const title = await page.title(); + console.log('页面标题:', title); + + // 检查是否有错误信息 + const bodyText = await page.locator('body').textContent(); + console.log('\n页面内容预览:'); + console.log(bodyText.substring(0, 500) + '...'); + + // 保持浏览器打开10秒以便查看 + console.log('\n浏览器将在10秒后关闭...'); + await page.waitForTimeout(10000); + + } catch (error) { + console.error('访问失败:', error.message); + + // 尝试获取更多错误信息 + if (error.message.includes('ERR_CONNECTION_REFUSED')) { + console.log('\n服务器可能未启动或端口错误'); + console.log('检查端口 5666 是否被占用...'); + } + } finally { + await browser.close(); + } +})(); \ No newline at end of file diff --git a/apps/web-finance/finance-system-error.png b/apps/web-finance/finance-system-error.png new file mode 100644 index 00000000..2270fb14 Binary files /dev/null and b/apps/web-finance/finance-system-error.png differ diff --git a/apps/web-finance/finance-system-test.png b/apps/web-finance/finance-system-test.png new file mode 100644 index 00000000..cbd36bf3 Binary files /dev/null and b/apps/web-finance/finance-system-test.png differ diff --git a/apps/web-finance/index.html b/apps/web-finance/index.html new file mode 100644 index 00000000..480eb84d --- /dev/null +++ b/apps/web-finance/index.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + <%= VITE_APP_TITLE %> + + + + +
+ + + diff --git a/apps/web-finance/login-page.png b/apps/web-finance/login-page.png new file mode 100644 index 00000000..f059b698 Binary files /dev/null and b/apps/web-finance/login-page.png differ diff --git a/apps/web-finance/manual-check.js b/apps/web-finance/manual-check.js new file mode 100644 index 00000000..d5b263de --- /dev/null +++ b/apps/web-finance/manual-check.js @@ -0,0 +1,73 @@ +import { chromium } from 'playwright'; + +(async () => { + const browser = await chromium.launch({ + headless: false, // 有头模式 + devtools: true // 打开开发者工具 + }); + + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 } + }); + + const page = await context.newPage(); + + // 监听控制台消息 + page.on('console', msg => { + if (msg.type() === 'error') { + console.log('❌ 控制台错误:', msg.text()); + } else if (msg.type() === 'warning') { + console.log('⚠️ 控制台警告:', msg.text()); + } + }); + + // 监听页面崩溃 + page.on('crash', () => { + console.log('💥 页面崩溃了!'); + }); + + // 监听网络错误 + page.on('response', response => { + if (response.status() >= 400) { + console.log(`🚫 网络错误 [${response.status()}]: ${response.url()}`); + } + }); + + console.log('================================='); + console.log('财务管理系统手动检查工具'); + console.log('=================================\n'); + + console.log('正在打开系统...'); + await page.goto('http://localhost:5666/', { + waitUntil: 'networkidle' + }); + + console.log('\n请手动执行以下操作:'); + console.log('1. 登录系统(用户名: vben, 密码: 123456)'); + console.log('2. 逐个点击以下菜单并检查是否正常:'); + console.log(' - 财务管理 > 财务概览'); + console.log(' - 财务管理 > 交易管理'); + console.log(' - 财务管理 > 分类管理'); + console.log(' - 财务管理 > 人员管理'); + console.log(' - 财务管理 > 贷款管理'); + console.log(' - 数据分析 > 数据概览'); + console.log(' - 数据分析 > 趋势分析'); + console.log(' - 系统工具 > 导入数据'); + console.log(' - 系统工具 > 导出数据'); + console.log(' - 系统工具 > 数据备份'); + console.log(' - 系统工具 > 预算管理'); + console.log(' - 系统工具 > 标签管理'); + + console.log('\n需要检查的内容:'); + console.log('✓ 页面是否正常加载'); + console.log('✓ 是否有错误提示'); + console.log('✓ 表格是否显示正常'); + console.log('✓ 按钮是否可以点击'); + console.log('✓ 图表是否正常显示(数据分析页面)'); + + console.log('\n控制台将实时显示错误信息...'); + console.log('按 Ctrl+C 结束检查\n'); + + // 保持浏览器开启 + await new Promise(() => {}); +})(); \ No newline at end of file diff --git a/apps/web-finance/package.json b/apps/web-finance/package.json new file mode 100644 index 00000000..b69f9e72 --- /dev/null +++ b/apps/web-finance/package.json @@ -0,0 +1,55 @@ +{ + "name": "@vben/web-finance", + "version": "1.0.0", + "homepage": "https://vben.pro", + "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/vbenjs/vue-vben-admin.git", + "directory": "apps/web-antd" + }, + "license": "MIT", + "author": { + "name": "vben", + "email": "ann.vben@gmail.com", + "url": "https://github.com/anncwb" + }, + "type": "module", + "scripts": { + "build": "pnpm vite build --mode production", + "build:analyze": "pnpm vite build --mode analyze", + "dev": "pnpm vite --mode development", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit --skipLibCheck" + }, + "imports": { + "#/*": "./src/*" + }, + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "@types/uuid": "^10.0.0", + "@vben/access": "workspace:*", + "@vben/common-ui": "workspace:*", + "@vben/constants": "workspace:*", + "@vben/hooks": "workspace:*", + "@vben/icons": "workspace:*", + "@vben/layouts": "workspace:*", + "@vben/locales": "workspace:*", + "@vben/plugins": "workspace:*", + "@vben/preferences": "workspace:*", + "@vben/request": "workspace:*", + "@vben/stores": "workspace:*", + "@vben/styles": "workspace:*", + "@vben/types": "workspace:*", + "@vben/utils": "workspace:*", + "@vueuse/core": "catalog:", + "ant-design-vue": "catalog:", + "dayjs": "catalog:", + "echarts": "catalog:", + "pinia": "catalog:", + "uuid": "^11.1.0", + "vue": "catalog:", + "vue-echarts": "^7.0.3", + "vue-router": "catalog:" + } +} diff --git a/apps/web-finance/postcss.config.mjs b/apps/web-finance/postcss.config.mjs new file mode 100644 index 00000000..3d807045 --- /dev/null +++ b/apps/web-finance/postcss.config.mjs @@ -0,0 +1 @@ +export { default } from '@vben/tailwind-config/postcss'; diff --git a/apps/web-finance/public/favicon.ico b/apps/web-finance/public/favicon.ico new file mode 100644 index 00000000..fcf9818e Binary files /dev/null and b/apps/web-finance/public/favicon.ico differ diff --git a/apps/web-finance/quick-test.js b/apps/web-finance/quick-test.js new file mode 100644 index 00000000..d9c39c90 --- /dev/null +++ b/apps/web-finance/quick-test.js @@ -0,0 +1,52 @@ +import { chromium } from 'playwright'; + +(async () => { + const browser = await chromium.launch({ + headless: false // 有头模式,方便观察 + }); + const context = await browser.newContext(); + const page = await context.newPage(); + + console.log('快速测试导入导出功能...\n'); + + try { + // 直接访问交易管理页面 + console.log('访问交易管理页面...'); + await page.goto('http://localhost:5666/finance/transaction'); + + // 等待页面加载 + await page.waitForTimeout(3000); + + // 截图 + await page.screenshot({ path: 'transaction-page.png' }); + console.log('页面截图已保存为 transaction-page.png'); + + // 测试导出CSV + console.log('\n尝试导出CSV...'); + try { + const exportBtn = page.locator('button:has-text("导出数据")'); + if (await exportBtn.isVisible()) { + await exportBtn.click(); + await page.waitForTimeout(500); + + // 点击CSV导出 + await page.locator('text="导出为CSV"').click(); + console.log('CSV导出操作已触发'); + } else { + console.log('导出按钮未找到'); + } + } catch (e) { + console.log('导出功能可能需要登录'); + } + + console.log('\n测试完成!'); + + } catch (error) { + console.error('测试失败:', error.message); + } + + // 保持浏览器打开20秒供查看 + console.log('\n浏览器将在20秒后关闭...'); + await page.waitForTimeout(20000); + await browser.close(); +})(); \ No newline at end of file diff --git a/apps/web-finance/src/adapter/component/index.ts b/apps/web-finance/src/adapter/component/index.ts new file mode 100644 index 00000000..786a93da --- /dev/null +++ b/apps/web-finance/src/adapter/component/index.ts @@ -0,0 +1,211 @@ +/** + * 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用 + * 可用于 vben-form、vben-modal、vben-drawer 等组件使用, + */ + +import type { Component } from 'vue'; + +import type { BaseFormComponentType } from '@vben/common-ui'; +import type { Recordable } from '@vben/types'; + +import { defineAsyncComponent, defineComponent, h, ref } from 'vue'; + +import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui'; +import { $t } from '@vben/locales'; + +import { notification } from 'ant-design-vue'; + +const AutoComplete = defineAsyncComponent( + () => import('ant-design-vue/es/auto-complete'), +); +const Button = defineAsyncComponent(() => import('ant-design-vue/es/button')); +const Checkbox = defineAsyncComponent( + () => import('ant-design-vue/es/checkbox'), +); +const CheckboxGroup = defineAsyncComponent(() => + import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup), +); +const DatePicker = defineAsyncComponent( + () => import('ant-design-vue/es/date-picker'), +); +const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider')); +const Input = defineAsyncComponent(() => import('ant-design-vue/es/input')); +const InputNumber = defineAsyncComponent( + () => import('ant-design-vue/es/input-number'), +); +const InputPassword = defineAsyncComponent(() => + import('ant-design-vue/es/input').then((res) => res.InputPassword), +); +const Mentions = defineAsyncComponent( + () => import('ant-design-vue/es/mentions'), +); +const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio')); +const RadioGroup = defineAsyncComponent(() => + import('ant-design-vue/es/radio').then((res) => res.RadioGroup), +); +const RangePicker = defineAsyncComponent(() => + import('ant-design-vue/es/date-picker').then((res) => res.RangePicker), +); +const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate')); +const Select = defineAsyncComponent(() => import('ant-design-vue/es/select')); +const Space = defineAsyncComponent(() => import('ant-design-vue/es/space')); +const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch')); +const Textarea = defineAsyncComponent(() => + import('ant-design-vue/es/input').then((res) => res.Textarea), +); +const TimePicker = defineAsyncComponent( + () => import('ant-design-vue/es/time-picker'), +); +const TreeSelect = defineAsyncComponent( + () => import('ant-design-vue/es/tree-select'), +); +const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload')); + +const withDefaultPlaceholder = ( + component: T, + type: 'input' | 'select', + componentProps: Recordable = {}, +) => { + return defineComponent({ + name: component.name, + inheritAttrs: false, + setup: (props: any, { attrs, expose, slots }) => { + const placeholder = + props?.placeholder || + attrs?.placeholder || + $t(`ui.placeholder.${type}`); + // 透传组件暴露的方法 + const innerRef = ref(); + expose( + new Proxy( + {}, + { + get: (_target, key) => innerRef.value?.[key], + has: (_target, key) => key in (innerRef.value || {}), + }, + ), + ); + return () => + h( + component, + { ...componentProps, placeholder, ...props, ...attrs, ref: innerRef }, + slots, + ); + }, + }); +}; + +// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明 +export type ComponentType = + | 'ApiSelect' + | 'ApiTreeSelect' + | 'AutoComplete' + | 'Checkbox' + | 'CheckboxGroup' + | 'DatePicker' + | 'DefaultButton' + | 'Divider' + | 'IconPicker' + | 'Input' + | 'InputNumber' + | 'InputPassword' + | 'Mentions' + | 'PrimaryButton' + | 'Radio' + | 'RadioGroup' + | 'RangePicker' + | 'Rate' + | 'Select' + | 'Space' + | 'Switch' + | 'Textarea' + | 'TimePicker' + | 'TreeSelect' + | 'Upload' + | BaseFormComponentType; + +async function initComponentAdapter() { + const components: Partial> = { + // 如果你的组件体积比较大,可以使用异步加载 + // Button: () => + // import('xxx').then((res) => res.Button), + ApiSelect: withDefaultPlaceholder( + { + ...ApiComponent, + name: 'ApiSelect', + }, + 'select', + { + component: Select, + loadingSlot: 'suffixIcon', + visibleEvent: 'onDropdownVisibleChange', + modelPropName: 'value', + }, + ), + ApiTreeSelect: withDefaultPlaceholder( + { + ...ApiComponent, + name: 'ApiTreeSelect', + }, + 'select', + { + component: TreeSelect, + fieldNames: { label: 'label', value: 'value', children: 'children' }, + loadingSlot: 'suffixIcon', + modelPropName: 'value', + optionsPropName: 'treeData', + visibleEvent: 'onVisibleChange', + }, + ), + AutoComplete, + Checkbox, + CheckboxGroup, + DatePicker, + // 自定义默认按钮 + DefaultButton: (props, { attrs, slots }) => { + return h(Button, { ...props, attrs, type: 'default' }, slots); + }, + Divider, + IconPicker: withDefaultPlaceholder(IconPicker, 'select', { + iconSlot: 'addonAfter', + inputComponent: Input, + modelValueProp: 'value', + }), + Input: withDefaultPlaceholder(Input, 'input'), + InputNumber: withDefaultPlaceholder(InputNumber, 'input'), + InputPassword: withDefaultPlaceholder(InputPassword, 'input'), + Mentions: withDefaultPlaceholder(Mentions, 'input'), + // 自定义主要按钮 + PrimaryButton: (props, { attrs, slots }) => { + return h(Button, { ...props, attrs, type: 'primary' }, slots); + }, + Radio, + RadioGroup, + RangePicker, + Rate, + Select: withDefaultPlaceholder(Select, 'select'), + Space, + Switch, + Textarea: withDefaultPlaceholder(Textarea, 'input'), + TimePicker, + TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'), + Upload, + }; + + // 将组件注册到全局共享状态中 + globalShareState.setComponents(components); + + // 定义全局共享状态中的消息提示 + globalShareState.defineMessage({ + // 复制成功消息提示 + copyPreferencesSuccess: (title, content) => { + notification.success({ + description: content, + message: title, + placement: 'bottomRight', + }); + }, + }); +} + +export { initComponentAdapter }; diff --git a/apps/web-finance/src/adapter/form.ts b/apps/web-finance/src/adapter/form.ts new file mode 100644 index 00000000..983a7f51 --- /dev/null +++ b/apps/web-finance/src/adapter/form.ts @@ -0,0 +1,49 @@ +import type { + VbenFormSchema as FormSchema, + VbenFormProps, +} from '@vben/common-ui'; + +import type { ComponentType } from './component'; + +import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui'; +import { $t } from '@vben/locales'; + +async function initSetupVbenForm() { + setupVbenForm({ + config: { + // ant design vue组件库默认都是 v-model:value + baseModelPropName: 'value', + + // 一些组件是 v-model:checked 或者 v-model:fileList + modelPropNameMap: { + Checkbox: 'checked', + Radio: 'checked', + Switch: 'checked', + Upload: 'fileList', + }, + }, + defineRules: { + // 输入项目必填国际化适配 + required: (value, _params, ctx) => { + if (value === undefined || value === null || value.length === 0) { + return $t('ui.formRules.required', [ctx.label]); + } + return true; + }, + // 选择项目必填国际化适配 + selectRequired: (value, _params, ctx) => { + if (value === undefined || value === null) { + return $t('ui.formRules.selectRequired', [ctx.label]); + } + return true; + }, + }, + }); +} + +const useVbenForm = useForm; + +export { initSetupVbenForm, useVbenForm, z }; + +export type VbenFormSchema = FormSchema; +export type { VbenFormProps }; diff --git a/apps/web-finance/src/adapter/vxe-table.ts b/apps/web-finance/src/adapter/vxe-table.ts new file mode 100644 index 00000000..7de2859d --- /dev/null +++ b/apps/web-finance/src/adapter/vxe-table.ts @@ -0,0 +1,69 @@ +import type { VxeTableGridOptions } from '@vben/plugins/vxe-table'; + +import { h } from 'vue'; + +import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table'; + +import { Button, Image } from 'ant-design-vue'; + +import { useVbenForm } from './form'; + +setupVbenVxeTable({ + configVxeTable: (vxeUI) => { + vxeUI.setConfig({ + grid: { + align: 'center', + border: false, + columnConfig: { + resizable: true, + }, + minHeight: 180, + formConfig: { + // 全局禁用vxe-table的表单配置,使用formOptions + enabled: false, + }, + proxyConfig: { + autoLoad: true, + response: { + result: 'items', + total: 'total', + list: 'items', + }, + showActiveMsg: true, + showResponseMsg: false, + }, + round: true, + showOverflow: true, + size: 'small', + } as VxeTableGridOptions, + }); + + // 表格配置项可以用 cellRender: { name: 'CellImage' }, + vxeUI.renderer.add('CellImage', { + renderTableDefault(_renderOpts, params) { + const { column, row } = params; + return h(Image, { src: row[column.field] }); + }, + }); + + // 表格配置项可以用 cellRender: { name: 'CellLink' }, + vxeUI.renderer.add('CellLink', { + renderTableDefault(renderOpts) { + const { props } = renderOpts; + return h( + Button, + { size: 'small', type: 'link' }, + { default: () => props?.text }, + ); + }, + }); + + // 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化 + // vxeUI.formats.add + }, + useVbenForm, +}); + +export { useVbenVxeGrid }; + +export type * from '@vben/plugins/vxe-table'; diff --git a/apps/web-finance/src/api/core/auth.ts b/apps/web-finance/src/api/core/auth.ts new file mode 100644 index 00000000..71d9f994 --- /dev/null +++ b/apps/web-finance/src/api/core/auth.ts @@ -0,0 +1,51 @@ +import { baseRequestClient, requestClient } from '#/api/request'; + +export namespace AuthApi { + /** 登录接口参数 */ + export interface LoginParams { + password?: string; + username?: string; + } + + /** 登录接口返回值 */ + export interface LoginResult { + accessToken: string; + } + + export interface RefreshTokenResult { + data: string; + status: number; + } +} + +/** + * 登录 + */ +export async function loginApi(data: AuthApi.LoginParams) { + return requestClient.post('/auth/login', data); +} + +/** + * 刷新accessToken + */ +export async function refreshTokenApi() { + return baseRequestClient.post('/auth/refresh', { + withCredentials: true, + }); +} + +/** + * 退出登录 + */ +export async function logoutApi() { + return baseRequestClient.post('/auth/logout', { + withCredentials: true, + }); +} + +/** + * 获取用户权限码 + */ +export async function getAccessCodesApi() { + return requestClient.get('/auth/codes'); +} diff --git a/apps/web-finance/src/api/core/index.ts b/apps/web-finance/src/api/core/index.ts new file mode 100644 index 00000000..28a5aef4 --- /dev/null +++ b/apps/web-finance/src/api/core/index.ts @@ -0,0 +1,3 @@ +export * from './auth'; +export * from './menu'; +export * from './user'; diff --git a/apps/web-finance/src/api/core/menu.ts b/apps/web-finance/src/api/core/menu.ts new file mode 100644 index 00000000..9ef60b11 --- /dev/null +++ b/apps/web-finance/src/api/core/menu.ts @@ -0,0 +1,10 @@ +import type { RouteRecordStringComponent } from '@vben/types'; + +import { requestClient } from '#/api/request'; + +/** + * 获取用户所有菜单 + */ +export async function getAllMenusApi() { + return requestClient.get('/menu/all'); +} diff --git a/apps/web-finance/src/api/core/user.ts b/apps/web-finance/src/api/core/user.ts new file mode 100644 index 00000000..7e28ea84 --- /dev/null +++ b/apps/web-finance/src/api/core/user.ts @@ -0,0 +1,10 @@ +import type { UserInfo } from '@vben/types'; + +import { requestClient } from '#/api/request'; + +/** + * 获取用户信息 + */ +export async function getUserInfoApi() { + return requestClient.get('/user/info'); +} diff --git a/apps/web-finance/src/api/finance/category.ts b/apps/web-finance/src/api/finance/category.ts new file mode 100644 index 00000000..e862f788 --- /dev/null +++ b/apps/web-finance/src/api/finance/category.ts @@ -0,0 +1,37 @@ +import type { Category, PageParams, PageResult } from '#/types/finance'; + +import { categoryService } from '#/api/mock/finance-service'; + +// 获取分类列表 +export async function getCategoryList(params?: PageParams) { + return categoryService.getList(params); +} + +// 获取分类详情 +export async function getCategoryDetail(id: string) { + const result = await categoryService.getDetail(id); + if (!result) { + throw new Error('Category not found'); + } + return result; +} + +// 创建分类 +export async function createCategory(data: Partial) { + return categoryService.create(data); +} + +// 更新分类 +export async function updateCategory(id: string, data: Partial) { + return categoryService.update(id, data); +} + +// 删除分类 +export async function deleteCategory(id: string) { + return categoryService.delete(id); +} + +// 获取分类树 +export async function getCategoryTree() { + return categoryService.getTree(); +} \ No newline at end of file diff --git a/apps/web-finance/src/api/finance/index.ts b/apps/web-finance/src/api/finance/index.ts new file mode 100644 index 00000000..4adb8b85 --- /dev/null +++ b/apps/web-finance/src/api/finance/index.ts @@ -0,0 +1,6 @@ +// 财务管理相关 API 导出 + +export * from './category'; +export * from './loan'; +export * from './person'; +export * from './transaction'; \ No newline at end of file diff --git a/apps/web-finance/src/api/finance/loan.ts b/apps/web-finance/src/api/finance/loan.ts new file mode 100644 index 00000000..069e68b1 --- /dev/null +++ b/apps/web-finance/src/api/finance/loan.ts @@ -0,0 +1,52 @@ +import type { + Loan, + LoanRepayment, + PageResult, + SearchParams +} from '#/types/finance'; + +import { loanService } from '#/api/mock/finance-service'; + +// 获取贷款列表 +export async function getLoanList(params: SearchParams) { + return loanService.getList(params); +} + +// 获取贷款详情 +export async function getLoanDetail(id: string) { + const result = await loanService.getDetail(id); + if (!result) { + throw new Error('Loan not found'); + } + return result; +} + +// 创建贷款 +export async function createLoan(data: Partial) { + return loanService.create(data); +} + +// 更新贷款 +export async function updateLoan(id: string, data: Partial) { + return loanService.update(id, data); +} + +// 删除贷款 +export async function deleteLoan(id: string) { + return loanService.delete(id); +} + +// 添加还款记录 +export async function addLoanRepayment(loanId: string, repayment: Partial) { + return loanService.addRepayment(loanId, repayment); +} + +// 更新贷款状态 +export async function updateLoanStatus(id: string, status: Loan['status']) { + return loanService.updateStatus(id, status); +} + +// 获取贷款统计 +export async function getLoanStatistics() { + return loanService.getStatistics(); +} \ No newline at end of file diff --git a/apps/web-finance/src/api/finance/person.ts b/apps/web-finance/src/api/finance/person.ts new file mode 100644 index 00000000..606d83a4 --- /dev/null +++ b/apps/web-finance/src/api/finance/person.ts @@ -0,0 +1,37 @@ +import type { PageParams, PageResult, Person } from '#/types/finance'; + +import { personService } from '#/api/mock/finance-service'; + +// 获取人员列表 +export async function getPersonList(params?: PageParams) { + return personService.getList(params); +} + +// 获取人员详情 +export async function getPersonDetail(id: string) { + const result = await personService.getDetail(id); + if (!result) { + throw new Error('Person not found'); + } + return result; +} + +// 创建人员 +export async function createPerson(data: Partial) { + return personService.create(data); +} + +// 更新人员 +export async function updatePerson(id: string, data: Partial) { + return personService.update(id, data); +} + +// 删除人员 +export async function deletePerson(id: string) { + return personService.delete(id); +} + +// 搜索人员 +export async function searchPersons(keyword: string) { + return personService.search(keyword); +} \ No newline at end of file diff --git a/apps/web-finance/src/api/finance/transaction.ts b/apps/web-finance/src/api/finance/transaction.ts new file mode 100644 index 00000000..c57d542c --- /dev/null +++ b/apps/web-finance/src/api/finance/transaction.ts @@ -0,0 +1,64 @@ +import type { + ExportParams, + ImportResult, + PageResult, + SearchParams, + Transaction +} from '#/types/finance'; + +import { transactionService } from '#/api/mock/finance-service'; + +// 获取交易列表 +export async function getTransactionList(params: SearchParams) { + return transactionService.getList(params); +} + +// 获取交易详情 +export async function getTransactionDetail(id: string) { + const result = await transactionService.getDetail(id); + if (!result) { + throw new Error('Transaction not found'); + } + return result; +} + +// 创建交易 +export async function createTransaction(data: Partial) { + return transactionService.create(data); +} + +// 更新交易 +export async function updateTransaction(id: string, data: Partial) { + return transactionService.update(id, data); +} + +// 删除交易 +export async function deleteTransaction(id: string) { + return transactionService.delete(id); +} + +// 批量删除交易 +export async function batchDeleteTransactions(ids: string[]) { + return transactionService.batchDelete(ids); +} + +// 导出交易 +export async function exportTransactions(params: ExportParams) { + // 暂时返回一个空的 Blob,实际实现需要根据参数生成文件 + return new Blob(['Export data'], { type: 'application/octet-stream' }); +} + +// 导入交易 +export async function importTransactions(file: File) { + // 暂时返回模拟结果,实际实现需要解析文件内容 + return { + success: 0, + failed: 0, + errors: [], + } as ImportResult; +} + +// 获取统计数据 +export async function getTransactionStatistics(params?: SearchParams) { + return transactionService.getStatistics(params); +} \ No newline at end of file diff --git a/apps/web-finance/src/api/index.ts b/apps/web-finance/src/api/index.ts new file mode 100644 index 00000000..4b0e0413 --- /dev/null +++ b/apps/web-finance/src/api/index.ts @@ -0,0 +1 @@ +export * from './core'; diff --git a/apps/web-finance/src/api/mock/finance-data.ts b/apps/web-finance/src/api/mock/finance-data.ts new file mode 100644 index 00000000..c9c116b3 --- /dev/null +++ b/apps/web-finance/src/api/mock/finance-data.ts @@ -0,0 +1,170 @@ +// Mock 数据生成工具 +import type { + Category, + Loan, + Person, + Transaction +} from '#/types/finance'; + +// 生成UUID +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substr(2); +} + +// 初始分类数据 +export const mockCategories: Category[] = [ + // 收入分类 + { id: '1', name: '工资', type: 'income', created_at: '2024-01-01' }, + { id: '2', name: '投资收益', type: 'income', created_at: '2024-01-01' }, + { id: '3', name: '兼职', type: 'income', created_at: '2024-01-01' }, + { id: '4', name: '奖金', type: 'income', created_at: '2024-01-01' }, + { id: '5', name: '其他收入', type: 'income', created_at: '2024-01-01' }, + + // 支出分类 + { id: '6', name: '餐饮', type: 'expense', created_at: '2024-01-01' }, + { id: '7', name: '交通', type: 'expense', created_at: '2024-01-01' }, + { id: '8', name: '购物', type: 'expense', created_at: '2024-01-01' }, + { id: '9', name: '娱乐', type: 'expense', created_at: '2024-01-01' }, + { id: '10', name: '住房', type: 'expense', created_at: '2024-01-01' }, + { id: '11', name: '医疗', type: 'expense', created_at: '2024-01-01' }, + { id: '12', name: '教育', type: 'expense', created_at: '2024-01-01' }, + { id: '13', name: '其他支出', type: 'expense', created_at: '2024-01-01' }, +]; + +// 初始人员数据 +export const mockPersons: Person[] = [ + { + id: '1', + name: '张三', + roles: ['payer', 'payee'], + contact: '13800138000', + description: '主要客户', + created_at: '2024-01-01', + }, + { + id: '2', + name: '李四', + roles: ['payee', 'borrower'], + contact: '13900139000', + description: '供应商', + created_at: '2024-01-01', + }, + { + id: '3', + name: '王五', + roles: ['payer', 'lender'], + contact: '13700137000', + description: '合作伙伴', + created_at: '2024-01-01', + }, + { + id: '4', + name: '赵六', + roles: ['payee'], + contact: '13600136000', + description: '员工', + created_at: '2024-01-01', + }, +]; + +// 生成随机交易数据 +export function generateMockTransactions(count: number = 50): Transaction[] { + const transactions: Transaction[] = []; + const currencies = ['USD', 'CNY', 'THB', 'MMK'] as const; + const statuses = ['pending', 'completed', 'cancelled'] as const; + const projects = ['项目A', '项目B', '项目C', '日常运营']; + + for (let i = 0; i < count; i++) { + const type = Math.random() > 0.4 ? 'expense' : 'income'; + const categoryIds = type === 'income' ? ['1', '2', '3', '4', '5'] : ['6', '7', '8', '9', '10', '11', '12', '13']; + const date = new Date(); + date.setDate(date.getDate() - Math.floor(Math.random() * 90)); // 最近90天的数据 + + transactions.push({ + id: generateId(), + amount: Math.floor(Math.random() * 10000) + 100, + type, + categoryId: categoryIds[Math.floor(Math.random() * categoryIds.length)], + description: `${type === 'income' ? '收入' : '支出'}记录 ${i + 1}`, + date: date.toISOString().split('T')[0], + quantity: Math.floor(Math.random() * 10) + 1, + project: projects[Math.floor(Math.random() * projects.length)], + payer: type === 'expense' ? '公司' : mockPersons[Math.floor(Math.random() * mockPersons.length)].name, + payee: type === 'income' ? '公司' : mockPersons[Math.floor(Math.random() * mockPersons.length)].name, + recorder: '管理员', + currency: currencies[Math.floor(Math.random() * currencies.length)], + status: statuses[Math.floor(Math.random() * statuses.length)], + created_at: date.toISOString(), + }); + } + + return transactions.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); +} + +// 生成贷款数据 +export function generateMockLoans(count: number = 10): Loan[] { + const loans: Loan[] = []; + const statuses = ['active', 'paid', 'overdue'] as const; + + for (let i = 0; i < count; i++) { + const startDate = new Date(); + startDate.setMonth(startDate.getMonth() - Math.floor(Math.random() * 12)); + + const dueDate = new Date(startDate); + dueDate.setMonth(dueDate.getMonth() + Math.floor(Math.random() * 12) + 1); + + const status = statuses[Math.floor(Math.random() * statuses.length)]; + const amount = Math.floor(Math.random() * 100000) + 10000; + + const loan: Loan = { + id: generateId(), + borrower: mockPersons[Math.floor(Math.random() * mockPersons.length)].name, + lender: mockPersons[Math.floor(Math.random() * mockPersons.length)].name, + amount, + currency: 'CNY', + startDate: startDate.toISOString().split('T')[0], + dueDate: dueDate.toISOString().split('T')[0], + description: `贷款合同 ${i + 1}`, + status, + repayments: [], + created_at: startDate.toISOString(), + }; + + // 生成还款记录 + if (status !== 'active') { + const repaymentCount = Math.floor(Math.random() * 5) + 1; + let totalRepaid = 0; + + for (let j = 0; j < repaymentCount; j++) { + const repaymentDate = new Date(startDate); + repaymentDate.setMonth(repaymentDate.getMonth() + j + 1); + + const repaymentAmount = Math.floor(amount / repaymentCount); + totalRepaid += repaymentAmount; + + loan.repayments.push({ + id: generateId(), + amount: repaymentAmount, + currency: 'CNY', + date: repaymentDate.toISOString().split('T')[0], + note: `第${j + 1}期还款`, + }); + } + + // 如果是已还清状态,确保还款总额等于贷款金额 + if (status === 'paid' && totalRepaid < amount) { + loan.repayments.push({ + id: generateId(), + amount: amount - totalRepaid, + currency: 'CNY', + date: new Date().toISOString().split('T')[0], + note: '最终还款', + }); + } + } + + loans.push(loan); + } + + return loans; +} \ No newline at end of file diff --git a/apps/web-finance/src/api/mock/finance-service.ts b/apps/web-finance/src/api/mock/finance-service.ts new file mode 100644 index 00000000..94b03910 --- /dev/null +++ b/apps/web-finance/src/api/mock/finance-service.ts @@ -0,0 +1,450 @@ +// Mock API 服务实现 +import type { + Category, + ImportResult, + Loan, + LoanRepayment, + PageParams, + PageResult, + Person, + SearchParams, + Transaction +} from '#/types/finance'; + +import { + add, + addBatch, + clear, + get, + getAll, + getByIndex, + initDB, + remove, + STORES, + update +} from '#/utils/db'; + +import { + generateMockLoans, + generateMockTransactions, + mockCategories, + mockPersons +} from './finance-data'; + +// 生成UUID +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substr(2); +} + +// 初始化数据 +export async function initializeData() { + try { + await initDB(); + + // 检查是否已有数据 + const existingCategories = await getAll(STORES.CATEGORIES); + if (existingCategories.length === 0) { + console.log('初始化Mock数据...'); + + // 初始化分类 + await addBatch(STORES.CATEGORIES, mockCategories); + console.log('分类数据已初始化'); + + // 初始化人员 + await addBatch(STORES.PERSONS, mockPersons); + console.log('人员数据已初始化'); + + // 初始化交易 + const transactions = generateMockTransactions(100); + await addBatch(STORES.TRANSACTIONS, transactions); + console.log('交易数据已初始化'); + + // 初始化贷款 + const loans = generateMockLoans(20); + await addBatch(STORES.LOANS, loans); + console.log('贷款数据已初始化'); + } else { + console.log('数据库已有数据,跳过初始化'); + } + } catch (error) { + console.error('初始化数据失败:', error); + throw error; + } +} + +// 分页处理 +function paginate(items: T[], params: PageParams): PageResult { + const { page = 1, pageSize = 20, sortBy, sortOrder = 'desc' } = params; + + // 排序 + if (sortBy && (items[0] as any)[sortBy] !== undefined) { + items.sort((a, b) => { + const aVal = (a as any)[sortBy]; + const bVal = (b as any)[sortBy]; + const order = sortOrder === 'asc' ? 1 : -1; + return aVal > bVal ? order : -order; + }); + } + + // 分页 + const start = (page - 1) * pageSize; + const end = start + pageSize; + const paginatedItems = items.slice(start, end); + + return { + items: paginatedItems, + total: items.length, + page, + pageSize, + totalPages: Math.ceil(items.length / pageSize), + }; +} + +// 搜索过滤 +function filterTransactions(transactions: Transaction[], params: SearchParams): Transaction[] { + let filtered = transactions; + + if (params.keyword) { + const keyword = params.keyword.toLowerCase(); + filtered = filtered.filter(t => + t.description?.toLowerCase().includes(keyword) || + t.project?.toLowerCase().includes(keyword) || + t.payer?.toLowerCase().includes(keyword) || + t.payee?.toLowerCase().includes(keyword) + ); + } + + if (params.type) { + filtered = filtered.filter(t => t.type === params.type); + } + + if (params.categoryId) { + filtered = filtered.filter(t => t.categoryId === params.categoryId); + } + + if (params.currency) { + filtered = filtered.filter(t => t.currency === params.currency); + } + + if (params.status) { + filtered = filtered.filter(t => t.status === params.status); + } + + if (params.dateFrom) { + filtered = filtered.filter(t => t.date >= params.dateFrom); + } + + if (params.dateTo) { + filtered = filtered.filter(t => t.date <= params.dateTo); + } + + return filtered; +} + +// Category API +export const categoryService = { + async getList(params?: PageParams): Promise> { + const categories = await getAll(STORES.CATEGORIES); + return paginate(categories, params || { page: 1, pageSize: 100 }); + }, + + async getDetail(id: string): Promise { + return get(STORES.CATEGORIES, id); + }, + + async create(data: Partial): Promise { + const category: Category = { + id: generateId(), + name: data.name!, + type: data.type!, + parentId: data.parentId, + created_at: new Date().toISOString(), + }; + await add(STORES.CATEGORIES, category); + return category; + }, + + async update(id: string, data: Partial): Promise { + const existing = await get(STORES.CATEGORIES, id); + if (!existing) { + throw new Error('Category not found'); + } + const updated = { ...existing, ...data, updated_at: new Date().toISOString() }; + await update(STORES.CATEGORIES, updated); + return updated; + }, + + async delete(id: string): Promise { + await remove(STORES.CATEGORIES, id); + }, + + async getTree(): Promise { + const categories = await getAll(STORES.CATEGORIES); + // 这里可以构建树形结构,暂时返回平铺数据 + return categories; + }, +}; + +// Transaction API +export const transactionService = { + async getList(params: SearchParams): Promise> { + const transactions = await getAll(STORES.TRANSACTIONS); + const filtered = filterTransactions(transactions, params); + return paginate(filtered, params); + }, + + async getDetail(id: string): Promise { + return get(STORES.TRANSACTIONS, id); + }, + + async create(data: Partial): Promise { + const transaction: Transaction = { + id: generateId(), + amount: data.amount!, + type: data.type!, + categoryId: data.categoryId!, + description: data.description, + date: data.date || new Date().toISOString().split('T')[0], + quantity: data.quantity || 1, + project: data.project, + payer: data.payer, + payee: data.payee, + recorder: data.recorder || '管理员', + currency: data.currency || 'CNY', + status: data.status || 'completed', + tags: data.tags || [], + created_at: new Date().toISOString(), + }; + await add(STORES.TRANSACTIONS, transaction); + return transaction; + }, + + async update(id: string, data: Partial): Promise { + const existing = await get(STORES.TRANSACTIONS, id); + if (!existing) { + throw new Error('Transaction not found'); + } + const updated = { ...existing, ...data, updated_at: new Date().toISOString() }; + await update(STORES.TRANSACTIONS, updated); + return updated; + }, + + async delete(id: string): Promise { + await remove(STORES.TRANSACTIONS, id); + }, + + async batchDelete(ids: string[]): Promise { + for (const id of ids) { + await remove(STORES.TRANSACTIONS, id); + } + }, + + async getStatistics(params?: SearchParams): Promise { + const transactions = await getAll(STORES.TRANSACTIONS); + const filtered = params ? filterTransactions(transactions, params) : transactions; + + const totalIncome = filtered + .filter(t => t.type === 'income' && t.status === 'completed') + .reduce((sum, t) => sum + t.amount, 0); + + const totalExpense = filtered + .filter(t => t.type === 'expense' && t.status === 'completed') + .reduce((sum, t) => sum + t.amount, 0); + + return { + totalIncome, + totalExpense, + balance: totalIncome - totalExpense, + totalTransactions: filtered.length, + }; + }, + + async import(data: Transaction[]): Promise { + const result: ImportResult = { + success: 0, + failed: 0, + errors: [], + }; + + for (let i = 0; i < data.length; i++) { + try { + await this.create(data[i]); + result.success++; + } catch (error) { + result.failed++; + result.errors.push({ + row: i + 1, + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + return result; + }, +}; + +// Person API +export const personService = { + async getList(params?: PageParams): Promise> { + const persons = await getAll(STORES.PERSONS); + return paginate(persons, params || { page: 1, pageSize: 100 }); + }, + + async getDetail(id: string): Promise { + return get(STORES.PERSONS, id); + }, + + async create(data: Partial): Promise { + const person: Person = { + id: generateId(), + name: data.name!, + roles: data.roles || [], + contact: data.contact, + description: data.description, + created_at: new Date().toISOString(), + }; + await add(STORES.PERSONS, person); + return person; + }, + + async update(id: string, data: Partial): Promise { + const existing = await get(STORES.PERSONS, id); + if (!existing) { + throw new Error('Person not found'); + } + const updated = { ...existing, ...data, updated_at: new Date().toISOString() }; + await update(STORES.PERSONS, updated); + return updated; + }, + + async delete(id: string): Promise { + await remove(STORES.PERSONS, id); + }, + + async search(keyword: string): Promise { + const persons = await getAll(STORES.PERSONS); + const lowercaseKeyword = keyword.toLowerCase(); + return persons.filter(p => + p.name.toLowerCase().includes(lowercaseKeyword) || + p.contact?.toLowerCase().includes(lowercaseKeyword) || + p.description?.toLowerCase().includes(lowercaseKeyword) + ); + }, +}; + +// Loan API +export const loanService = { + async getList(params: SearchParams): Promise> { + const loans = await getAll(STORES.LOANS); + let filtered = loans; + + if (params.status) { + filtered = filtered.filter(l => l.status === params.status); + } + + if (params.keyword) { + const keyword = params.keyword.toLowerCase(); + filtered = filtered.filter(l => + l.borrower.toLowerCase().includes(keyword) || + l.lender.toLowerCase().includes(keyword) || + l.description?.toLowerCase().includes(keyword) + ); + } + + return paginate(filtered, params); + }, + + async getDetail(id: string): Promise { + return get(STORES.LOANS, id); + }, + + async create(data: Partial): Promise { + const loan: Loan = { + id: generateId(), + borrower: data.borrower!, + lender: data.lender!, + amount: data.amount!, + currency: data.currency || 'CNY', + startDate: data.startDate || new Date().toISOString().split('T')[0], + dueDate: data.dueDate, + description: data.description, + status: data.status || 'active', + repayments: [], + created_at: new Date().toISOString(), + }; + await add(STORES.LOANS, loan); + return loan; + }, + + async update(id: string, data: Partial): Promise { + const existing = await get(STORES.LOANS, id); + if (!existing) { + throw new Error('Loan not found'); + } + const updated = { ...existing, ...data, updated_at: new Date().toISOString() }; + await update(STORES.LOANS, updated); + return updated; + }, + + async delete(id: string): Promise { + await remove(STORES.LOANS, id); + }, + + async addRepayment(loanId: string, repayment: Partial): Promise { + const loan = await get(STORES.LOANS, loanId); + if (!loan) { + throw new Error('Loan not found'); + } + + const newRepayment: LoanRepayment = { + id: generateId(), + amount: repayment.amount!, + currency: repayment.currency || loan.currency, + date: repayment.date || new Date().toISOString().split('T')[0], + note: repayment.note, + }; + + loan.repayments.push(newRepayment); + + // 检查是否已还清 + const totalRepaid = loan.repayments.reduce((sum, r) => sum + r.amount, 0); + if (totalRepaid >= loan.amount) { + loan.status = 'paid'; + } + + await update(STORES.LOANS, loan); + return loan; + }, + + async updateStatus(id: string, status: Loan['status']): Promise { + const loan = await get(STORES.LOANS, id); + if (!loan) { + throw new Error('Loan not found'); + } + loan.status = status; + await update(STORES.LOANS, loan); + return loan; + }, + + async getStatistics(): Promise { + const loans = await getAll(STORES.LOANS); + + const activeLoans = loans.filter(l => l.status === 'active'); + const paidLoans = loans.filter(l => l.status === 'paid'); + const overdueLoans = loans.filter(l => l.status === 'overdue'); + + const totalLent = loans.reduce((sum, l) => sum + l.amount, 0); + const totalRepaid = loans.reduce((sum, l) => + sum + l.repayments.reduce((repaySum, r) => repaySum + r.amount, 0), 0 + ); + + return { + totalLent, + totalBorrowed: totalLent, // 在实际应用中可能需要区分 + totalRepaid, + activeLoans: activeLoans.length, + overdueLoans: overdueLoans.length, + paidLoans: paidLoans.length, + }; + }, +}; \ No newline at end of file diff --git a/apps/web-finance/src/api/request.ts b/apps/web-finance/src/api/request.ts new file mode 100644 index 00000000..288dddd0 --- /dev/null +++ b/apps/web-finance/src/api/request.ts @@ -0,0 +1,113 @@ +/** + * 该文件可自行根据业务逻辑进行调整 + */ +import type { RequestClientOptions } from '@vben/request'; + +import { useAppConfig } from '@vben/hooks'; +import { preferences } from '@vben/preferences'; +import { + authenticateResponseInterceptor, + defaultResponseInterceptor, + errorMessageResponseInterceptor, + RequestClient, +} from '@vben/request'; +import { useAccessStore } from '@vben/stores'; + +import { message } from 'ant-design-vue'; + +import { useAuthStore } from '#/store'; + +import { refreshTokenApi } from './core'; + +const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); + +function createRequestClient(baseURL: string, options?: RequestClientOptions) { + const client = new RequestClient({ + ...options, + baseURL, + }); + + /** + * 重新认证逻辑 + */ + async function doReAuthenticate() { + console.warn('Access token or refresh token is invalid or expired. '); + const accessStore = useAccessStore(); + const authStore = useAuthStore(); + accessStore.setAccessToken(null); + if ( + preferences.app.loginExpiredMode === 'modal' && + accessStore.isAccessChecked + ) { + accessStore.setLoginExpired(true); + } else { + await authStore.logout(); + } + } + + /** + * 刷新token逻辑 + */ + async function doRefreshToken() { + const accessStore = useAccessStore(); + const resp = await refreshTokenApi(); + const newToken = resp.data; + accessStore.setAccessToken(newToken); + return newToken; + } + + function formatToken(token: null | string) { + return token ? `Bearer ${token}` : null; + } + + // 请求头处理 + client.addRequestInterceptor({ + fulfilled: async (config) => { + const accessStore = useAccessStore(); + + config.headers.Authorization = formatToken(accessStore.accessToken); + config.headers['Accept-Language'] = preferences.app.locale; + return config; + }, + }); + + // 处理返回的响应数据格式 + client.addResponseInterceptor( + defaultResponseInterceptor({ + codeField: 'code', + dataField: 'data', + successCode: 0, + }), + ); + + // token过期的处理 + client.addResponseInterceptor( + authenticateResponseInterceptor({ + client, + doReAuthenticate, + doRefreshToken, + enableRefreshToken: preferences.app.enableRefreshToken, + formatToken, + }), + ); + + // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里 + 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 ?? ''; + // 如果没有错误信息,则会根据状态码进行提示 + message.error(errorMessage || msg); + }), + ); + + return client; +} + +export const requestClient = createRequestClient(apiURL, { + responseReturn: 'data', +}); + +export const baseRequestClient = new RequestClient({ baseURL: apiURL }); diff --git a/apps/web-finance/src/app.vue b/apps/web-finance/src/app.vue new file mode 100644 index 00000000..c4fb81f3 --- /dev/null +++ b/apps/web-finance/src/app.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/web-finance/src/bootstrap.ts b/apps/web-finance/src/bootstrap.ts new file mode 100644 index 00000000..37b6a1bd --- /dev/null +++ b/apps/web-finance/src/bootstrap.ts @@ -0,0 +1,93 @@ +import { createApp, watchEffect } from 'vue'; + +import { registerAccessDirective } from '@vben/access'; +import { registerLoadingDirective } from '@vben/common-ui/es/loading'; +import { preferences } from '@vben/preferences'; +import { initStores } from '@vben/stores'; +import '@vben/styles'; +import '@vben/styles/antd'; +import '#/styles/mobile.css'; + +import { useTitle } from '@vueuse/core'; + +import { initializeData } from '#/api/mock/finance-service'; +import { $t, setupI18n } from '#/locales'; +import { migrateData, needsMigration } from '#/utils/data-migration'; + +import { initComponentAdapter } from './adapter/component'; +import { initSetupVbenForm } from './adapter/form'; +import App from './app.vue'; +import { router } from './router'; + +async function bootstrap(namespace: string) { + // 初始化数据库和 Mock 数据 + await initializeData(); + + // 检查并执行数据迁移 + if (needsMigration()) { + console.log('检测到旧数据,开始迁移...'); + const result = await migrateData(); + if (result.success) { + console.log(result.message, result.details); + } else { + console.error(result.message, result.details); + } + } + + // 初始化组件适配器 + await initComponentAdapter(); + + // 初始化表单组件 + await initSetupVbenForm(); + + // // 设置弹窗的默认配置 + // setDefaultModalProps({ + // fullscreenButton: false, + // }); + // // 设置抽屉的默认配置 + // setDefaultDrawerProps({ + // zIndex: 1020, + // }); + + const app = createApp(App); + + // 注册v-loading指令 + registerLoadingDirective(app, { + loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令 + spinning: 'spinning', + }); + + // 国际化 i18n 配置 + await setupI18n(app); + + // 配置 pinia-tore + await initStores(app, { namespace }); + + // 安装权限指令 + registerAccessDirective(app); + + // 初始化 tippy + const { initTippy } = await import('@vben/common-ui/es/tippy'); + initTippy(app); + + // 配置路由及路由守卫 + app.use(router); + + // 配置Motion插件 + const { MotionPlugin } = await import('@vben/plugins/motion'); + app.use(MotionPlugin); + + // 动态更新标题 + watchEffect(() => { + if (preferences.app.dynamicTitle) { + const routeTitle = router.currentRoute.value.meta?.title; + const pageTitle = + (routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name; + useTitle(pageTitle); + } + }); + + app.mount('#app'); +} + +export { bootstrap }; diff --git a/apps/web-finance/src/components/charts/useChart.ts b/apps/web-finance/src/components/charts/useChart.ts new file mode 100644 index 00000000..4bf15c07 --- /dev/null +++ b/apps/web-finance/src/components/charts/useChart.ts @@ -0,0 +1,147 @@ +import type * as echarts from 'echarts'; +import type { Ref } from 'vue'; + +import { computed, nextTick, onMounted, onUnmounted, ref, unref, watch } from 'vue'; + +import { useDebounceFn } from '@vueuse/core'; +import * as echartCore from 'echarts/core'; +import { BarChart, LineChart, PieChart } from 'echarts/charts'; +import { + DataZoomComponent, + GridComponent, + LegendComponent, + TitleComponent, + ToolboxComponent, + TooltipComponent, +} from 'echarts/components'; +import { LabelLayout, UniversalTransition } from 'echarts/features'; +import { CanvasRenderer } from 'echarts/renderers'; + +// 注册必要的组件 +echartCore.use([ + TitleComponent, + TooltipComponent, + GridComponent, + LegendComponent, + ToolboxComponent, + DataZoomComponent, + BarChart, + LineChart, + PieChart, + CanvasRenderer, + UniversalTransition, + LabelLayout, +]); + +export type EChartsOption = echarts.EChartsOption; +export type EChartsInstance = echarts.ECharts; + +export interface UseChartOptions { + theme?: string | object; + initOptions?: echarts.EChartsCoreOption; + loading?: boolean; + loadingOptions?: object; +} + +export function useChart( + elRef: Ref, + options: UseChartOptions = {}, +) { + const { theme = 'light', initOptions = {}, loading = false, loadingOptions = {} } = options; + + let chartInstance: EChartsInstance | null = null; + const cacheOptions = ref({}); + const isDisposed = ref(false); + + // 获取图表实例 + const getChartInstance = (): EChartsInstance | null => { + if (!elRef.value || isDisposed.value) { + return null; + } + + if (!chartInstance) { + chartInstance = echartCore.init(elRef.value, theme, initOptions); + } + + return chartInstance; + }; + + // 设置图表配置 + const setOptions = (options: EChartsOption, clear = true) => { + cacheOptions.value = options; + nextTick(() => { + if (!isDisposed.value) { + const instance = getChartInstance(); + if (instance) { + clear && instance.clear(); + instance.setOption(options); + } + } + }); + }; + + // 获取图表配置 + const getOptions = (): EChartsOption => { + return cacheOptions.value; + }; + + // 调整图表大小 + const resize = useDebounceFn(() => { + const instance = getChartInstance(); + instance?.resize(); + }, 200); + + // 销毁图表 + const dispose = () => { + if (chartInstance) { + chartInstance.dispose(); + chartInstance = null; + isDisposed.value = true; + } + }; + + // 监听 loading 状态 + watch( + () => loading, + (val) => { + const instance = getChartInstance(); + if (instance) { + if (val) { + instance.showLoading(loadingOptions); + } else { + instance.hideLoading(); + } + } + }, + ); + + // 监听元素变化,重新初始化 + watch( + elRef, + (el) => { + if (el) { + isDisposed.value = false; + setOptions(cacheOptions.value); + } + }, + ); + + // 挂载时初始化 + onMounted(() => { + window.addEventListener('resize', resize); + }); + + // 卸载时清理 + onUnmounted(() => { + window.removeEventListener('resize', resize); + dispose(); + }); + + return { + getInstance: getChartInstance, + setOptions, + getOptions, + resize, + dispose, + }; +} \ No newline at end of file diff --git a/apps/web-finance/src/layouts/auth.vue b/apps/web-finance/src/layouts/auth.vue new file mode 100644 index 00000000..18d415bc --- /dev/null +++ b/apps/web-finance/src/layouts/auth.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/web-finance/src/layouts/basic.vue b/apps/web-finance/src/layouts/basic.vue new file mode 100644 index 00000000..1481dc5a --- /dev/null +++ b/apps/web-finance/src/layouts/basic.vue @@ -0,0 +1,157 @@ + + + diff --git a/apps/web-finance/src/layouts/index.ts b/apps/web-finance/src/layouts/index.ts new file mode 100644 index 00000000..a4320780 --- /dev/null +++ b/apps/web-finance/src/layouts/index.ts @@ -0,0 +1,6 @@ +const BasicLayout = () => import('./basic.vue'); +const AuthPageLayout = () => import('./auth.vue'); + +const IFrameView = () => import('@vben/layouts').then((m) => m.IFrameView); + +export { AuthPageLayout, BasicLayout, IFrameView }; diff --git a/apps/web-finance/src/locales/README.md b/apps/web-finance/src/locales/README.md new file mode 100644 index 00000000..7b451032 --- /dev/null +++ b/apps/web-finance/src/locales/README.md @@ -0,0 +1,3 @@ +# locale + +每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。 diff --git a/apps/web-finance/src/locales/index.ts b/apps/web-finance/src/locales/index.ts new file mode 100644 index 00000000..7f32bd18 --- /dev/null +++ b/apps/web-finance/src/locales/index.ts @@ -0,0 +1,102 @@ +import type { Locale } from 'ant-design-vue/es/locale'; + +import type { App } from 'vue'; + +import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales'; + +import { ref } from 'vue'; + +import { + $t, + setupI18n as coreSetup, + loadLocalesMapFromDir, +} from '@vben/locales'; +import { preferences } from '@vben/preferences'; + +import antdEnLocale from 'ant-design-vue/es/locale/en_US'; +import antdDefaultLocale from 'ant-design-vue/es/locale/zh_CN'; +import dayjs from 'dayjs'; + +const antdLocale = ref(antdDefaultLocale); + +const modules = import.meta.glob('./langs/**/*.json'); + +const localesMap = loadLocalesMapFromDir( + /\.\/langs\/([^/]+)\/(.*)\.json$/, + modules, +); +/** + * 加载应用特有的语言包 + * 这里也可以改造为从服务端获取翻译数据 + * @param lang + */ +async function loadMessages(lang: SupportedLanguagesType) { + const [appLocaleMessages] = await Promise.all([ + localesMap[lang]?.(), + loadThirdPartyMessage(lang), + ]); + return appLocaleMessages?.default; +} + +/** + * 加载第三方组件库的语言包 + * @param lang + */ +async function loadThirdPartyMessage(lang: SupportedLanguagesType) { + await Promise.all([loadAntdLocale(lang), loadDayjsLocale(lang)]); +} + +/** + * 加载dayjs的语言包 + * @param lang + */ +async function loadDayjsLocale(lang: SupportedLanguagesType) { + let locale; + switch (lang) { + case 'en-US': { + locale = await import('dayjs/locale/en'); + break; + } + case 'zh-CN': { + locale = await import('dayjs/locale/zh-cn'); + break; + } + // 默认使用英语 + default: { + locale = await import('dayjs/locale/en'); + } + } + if (locale) { + dayjs.locale(locale); + } else { + console.error(`Failed to load dayjs locale for ${lang}`); + } +} + +/** + * 加载antd的语言包 + * @param lang + */ +async function loadAntdLocale(lang: SupportedLanguagesType) { + switch (lang) { + case 'en-US': { + antdLocale.value = antdEnLocale; + break; + } + case 'zh-CN': { + antdLocale.value = antdDefaultLocale; + break; + } + } +} + +async function setupI18n(app: App, options: LocaleSetupOptions = {}) { + await coreSetup(app, { + defaultLocale: preferences.app.locale, + loadMessages, + missingWarn: !import.meta.env.PROD, + ...options, + }); +} + +export { $t, antdLocale, setupI18n }; diff --git a/apps/web-finance/src/locales/langs/en-US/demos.json b/apps/web-finance/src/locales/langs/en-US/demos.json new file mode 100644 index 00000000..07156434 --- /dev/null +++ b/apps/web-finance/src/locales/langs/en-US/demos.json @@ -0,0 +1,12 @@ +{ + "title": "Demos", + "antd": "Ant Design Vue", + "vben": { + "title": "Project", + "about": "About", + "document": "Document", + "antdv": "Ant Design Vue Version", + "naive-ui": "Naive UI Version", + "element-plus": "Element Plus Version" + } +} diff --git a/apps/web-finance/src/locales/langs/en-US/page.json b/apps/web-finance/src/locales/langs/en-US/page.json new file mode 100644 index 00000000..618a258c --- /dev/null +++ b/apps/web-finance/src/locales/langs/en-US/page.json @@ -0,0 +1,14 @@ +{ + "auth": { + "login": "Login", + "register": "Register", + "codeLogin": "Code Login", + "qrcodeLogin": "Qr Code Login", + "forgetPassword": "Forget Password" + }, + "dashboard": { + "title": "Dashboard", + "analytics": "Analytics", + "workspace": "Workspace" + } +} diff --git a/apps/web-finance/src/locales/langs/zh-CN/analytics.json b/apps/web-finance/src/locales/langs/zh-CN/analytics.json new file mode 100644 index 00000000..6b235e0b --- /dev/null +++ b/apps/web-finance/src/locales/langs/zh-CN/analytics.json @@ -0,0 +1,42 @@ +{ + "title": "统计分析", + "overview": "概览", + "trends": "趋势分析", + "reports": "报表", + "reports.daily": "日报表", + "reports.monthly": "月报表", + "reports.yearly": "年报表", + "reports.custom": "自定义报表", + + "statistics.totalIncome": "总收入", + "statistics.totalExpense": "总支出", + "statistics.balance": "余额", + "statistics.transactions": "交易数", + "statistics.avgDaily": "日均", + "statistics.avgMonthly": "月均", + + "chart.incomeExpense": "收支趋势", + "chart.categoryDistribution": "分类分布", + "chart.monthlyComparison": "月度对比", + "chart.personAnalysis": "人员分析", + "chart.projectAnalysis": "项目分析", + + "period.today": "今日", + "period.yesterday": "昨日", + "period.thisWeek": "本周", + "period.lastWeek": "上周", + "period.thisMonth": "本月", + "period.lastMonth": "上月", + "period.thisQuarter": "本季度", + "period.lastQuarter": "上季度", + "period.thisYear": "今年", + "period.lastYear": "去年", + "period.custom": "自定义", + + "filter.dateRange": "日期范围", + "filter.category": "分类", + "filter.person": "人员", + "filter.project": "项目", + "filter.currency": "货币", + "filter.type": "类型" +} \ No newline at end of file diff --git a/apps/web-finance/src/locales/langs/zh-CN/demos.json b/apps/web-finance/src/locales/langs/zh-CN/demos.json new file mode 100644 index 00000000..93ee722f --- /dev/null +++ b/apps/web-finance/src/locales/langs/zh-CN/demos.json @@ -0,0 +1,12 @@ +{ + "title": "演示", + "antd": "Ant Design Vue", + "vben": { + "title": "项目", + "about": "关于", + "document": "文档", + "antdv": "Ant Design Vue 版本", + "naive-ui": "Naive UI 版本", + "element-plus": "Element Plus 版本" + } +} diff --git a/apps/web-finance/src/locales/langs/zh-CN/finance.json b/apps/web-finance/src/locales/langs/zh-CN/finance.json new file mode 100644 index 00000000..6bca7276 --- /dev/null +++ b/apps/web-finance/src/locales/langs/zh-CN/finance.json @@ -0,0 +1,90 @@ +{ + "title": "财务管理", + "dashboard": "仪表板", + "transaction": "交易管理", + "category": "分类管理", + "person": "人员管理", + "loan": "贷款管理", + "tag": "标签管理", + "budget": "预算管理", + "mobile": "移动端", + + "transaction.list": "交易列表", + "transaction.create": "新建交易", + "transaction.edit": "编辑交易", + "transaction.delete": "删除交易", + "transaction.batchDelete": "批量删除", + "transaction.export": "导出交易", + "transaction.import": "导入交易", + + "transaction.amount": "金额", + "transaction.type": "类型", + "transaction.category": "分类", + "transaction.date": "日期", + "transaction.description": "描述", + "transaction.project": "项目", + "transaction.payer": "付款人", + "transaction.payee": "收款人", + "transaction.recorder": "记录人", + "transaction.currency": "货币", + "transaction.status": "状态", + + "type.income": "收入", + "type.expense": "支出", + + "status.pending": "待处理", + "status.completed": "已完成", + "status.cancelled": "已取消", + + "currency.USD": "美元", + "currency.CNY": "人民币", + "currency.THB": "泰铢", + "currency.MMK": "缅元", + + "category.income": "收入分类", + "category.expense": "支出分类", + "category.create": "新建分类", + "category.edit": "编辑分类", + "category.delete": "删除分类", + + "person.list": "人员列表", + "person.create": "新建人员", + "person.edit": "编辑人员", + "person.delete": "删除人员", + "person.roles": "角色", + "person.contact": "联系方式", + + "role.payer": "付款人", + "role.payee": "收款人", + "role.borrower": "借款人", + "role.lender": "出借人", + + "loan.list": "贷款列表", + "loan.create": "新建贷款", + "loan.edit": "编辑贷款", + "loan.delete": "删除贷款", + "loan.borrower": "借款人", + "loan.lender": "出借人", + "loan.startDate": "开始日期", + "loan.dueDate": "到期日期", + "loan.repayment": "还款记录", + "loan.addRepayment": "添加还款", + + "loan.status.active": "进行中", + "loan.status.paid": "已还清", + "loan.status.overdue": "已逾期", + + "common.search": "搜索", + "common.reset": "重置", + "common.create": "新建", + "common.edit": "编辑", + "common.delete": "删除", + "common.save": "保存", + "common.cancel": "取消", + "common.confirm": "确认", + "common.export": "导出", + "common.import": "导入", + "common.actions": "操作", + "common.loading": "加载中...", + "common.noData": "暂无数据" +} \ No newline at end of file diff --git a/apps/web-finance/src/locales/langs/zh-CN/page.json b/apps/web-finance/src/locales/langs/zh-CN/page.json new file mode 100644 index 00000000..967707d0 --- /dev/null +++ b/apps/web-finance/src/locales/langs/zh-CN/page.json @@ -0,0 +1,23 @@ +{ + "auth": { + "login": "登录", + "register": "注册", + "codeLogin": "验证码登录", + "qrcodeLogin": "二维码登录", + "forgetPassword": "忘记密码" + }, + "dashboard": { + "title": "概览", + "analytics": "分析页", + "workspace": "工作台" + }, + "finance": { + "title": "财务管理" + }, + "analytics": { + "title": "统计分析" + }, + "tools": { + "title": "系统工具" + } +} diff --git a/apps/web-finance/src/locales/langs/zh-CN/tools.json b/apps/web-finance/src/locales/langs/zh-CN/tools.json new file mode 100644 index 00000000..c17b4b77 --- /dev/null +++ b/apps/web-finance/src/locales/langs/zh-CN/tools.json @@ -0,0 +1,62 @@ +{ + "title": "系统工具", + "import": "数据导入", + "export": "数据导出", + "backup": "数据备份", + "budget": "预算管理", + "tags": "标签管理", + + "import.title": "导入数据", + "import.selectFile": "选择文件", + "import.downloadTemplate": "下载模板", + "import.preview": "预览数据", + "import.mapping": "字段映射", + "import.start": "开始导入", + "import.success": "导入成功", + "import.failed": "导入失败", + "import.result": "导入结果", + "import.successCount": "成功条数", + "import.failedCount": "失败条数", + + "export.title": "导出数据", + "export.selectType": "选择类型", + "export.selectFields": "选择字段", + "export.format": "导出格式", + "export.excel": "Excel文件", + "export.csv": "CSV文件", + "export.pdf": "PDF文件", + "export.dateRange": "日期范围", + "export.filters": "筛选条件", + + "backup.title": "数据备份", + "backup.create": "创建备份", + "backup.restore": "恢复备份", + "backup.download": "下载备份", + "backup.delete": "删除备份", + "backup.auto": "自动备份", + "backup.manual": "手动备份", + "backup.schedule": "备份计划", + "backup.lastBackup": "最后备份", + + "budget.title": "预算管理", + "budget.create": "创建预算", + "budget.edit": "编辑预算", + "budget.delete": "删除预算", + "budget.monthly": "月度预算", + "budget.yearly": "年度预算", + "budget.category": "分类预算", + "budget.amount": "预算金额", + "budget.used": "已使用", + "budget.remaining": "剩余", + "budget.progress": "执行进度", + "budget.alert": "预警设置", + + "tags.title": "标签管理", + "tags.create": "创建标签", + "tags.edit": "编辑标签", + "tags.delete": "删除标签", + "tags.name": "标签名称", + "tags.color": "标签颜色", + "tags.description": "标签描述", + "tags.usage": "使用次数" +} \ No newline at end of file diff --git a/apps/web-finance/src/main.ts b/apps/web-finance/src/main.ts new file mode 100644 index 00000000..5d728a02 --- /dev/null +++ b/apps/web-finance/src/main.ts @@ -0,0 +1,31 @@ +import { initPreferences } from '@vben/preferences'; +import { unmountGlobalLoading } from '@vben/utils'; + +import { overridesPreferences } from './preferences'; + +/** + * 应用初始化完成之后再进行页面加载渲染 + */ +async function initApplication() { + // name用于指定项目唯一标识 + // 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据 + const env = import.meta.env.PROD ? 'prod' : 'dev'; + const appVersion = import.meta.env.VITE_APP_VERSION; + const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`; + + // app偏好设置初始化 + await initPreferences({ + namespace, + overrides: overridesPreferences, + }); + + // 启动应用并挂载 + // vue应用主要逻辑及视图 + const { bootstrap } = await import('./bootstrap'); + await bootstrap(namespace); + + // 移除并销毁loading + unmountGlobalLoading(); +} + +initApplication(); diff --git a/apps/web-finance/src/preferences.ts b/apps/web-finance/src/preferences.ts new file mode 100644 index 00000000..b2e9ace4 --- /dev/null +++ b/apps/web-finance/src/preferences.ts @@ -0,0 +1,13 @@ +import { defineOverridesPreferences } from '@vben/preferences'; + +/** + * @description 项目配置文件 + * 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置 + * !!! 更改配置后请清空缓存,否则可能不生效 + */ +export const overridesPreferences = defineOverridesPreferences({ + // overrides + app: { + name: import.meta.env.VITE_APP_TITLE, + }, +}); diff --git a/apps/web-finance/src/router/access.ts b/apps/web-finance/src/router/access.ts new file mode 100644 index 00000000..3a48be23 --- /dev/null +++ b/apps/web-finance/src/router/access.ts @@ -0,0 +1,42 @@ +import type { + ComponentRecordType, + GenerateMenuAndRoutesOptions, +} from '@vben/types'; + +import { generateAccessible } from '@vben/access'; +import { preferences } from '@vben/preferences'; + +import { message } from 'ant-design-vue'; + +import { getAllMenusApi } from '#/api'; +import { BasicLayout, IFrameView } from '#/layouts'; +import { $t } from '#/locales'; + +const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue'); + +async function generateAccess(options: GenerateMenuAndRoutesOptions) { + const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue'); + + const layoutMap: ComponentRecordType = { + BasicLayout, + IFrameView, + }; + + return await generateAccessible(preferences.app.accessMode, { + ...options, + fetchMenuListAsync: async () => { + message.loading({ + content: `${$t('common.loadingMenu')}...`, + duration: 1.5, + }); + return await getAllMenusApi(); + }, + // 可以指定没有权限跳转403页面 + forbiddenComponent, + // 如果 route.meta.menuVisibleWithForbidden = true + layoutMap, + pageMap, + }); +} + +export { generateAccess }; diff --git a/apps/web-finance/src/router/guard.ts b/apps/web-finance/src/router/guard.ts new file mode 100644 index 00000000..a1ad6d88 --- /dev/null +++ b/apps/web-finance/src/router/guard.ts @@ -0,0 +1,133 @@ +import type { Router } from 'vue-router'; + +import { LOGIN_PATH } from '@vben/constants'; +import { preferences } from '@vben/preferences'; +import { useAccessStore, useUserStore } from '@vben/stores'; +import { startProgress, stopProgress } from '@vben/utils'; + +import { accessRoutes, coreRouteNames } from '#/router/routes'; +import { useAuthStore } from '#/store'; + +import { generateAccess } from './access'; + +/** + * 通用守卫配置 + * @param router + */ +function setupCommonGuard(router: Router) { + // 记录已经加载的页面 + const loadedPaths = new Set(); + + router.beforeEach((to) => { + to.meta.loaded = loadedPaths.has(to.path); + + // 页面加载进度条 + if (!to.meta.loaded && preferences.transition.progress) { + startProgress(); + } + return true; + }); + + router.afterEach((to) => { + // 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行 + + loadedPaths.add(to.path); + + // 关闭页面加载进度条 + if (preferences.transition.progress) { + stopProgress(); + } + }); +} + +/** + * 权限访问守卫配置 + * @param router + */ +function setupAccessGuard(router: Router) { + router.beforeEach(async (to, from) => { + const accessStore = useAccessStore(); + const userStore = useUserStore(); + const authStore = useAuthStore(); + + // 基本路由,这些路由不需要进入权限拦截 + if (coreRouteNames.includes(to.name as string)) { + if (to.path === LOGIN_PATH && accessStore.accessToken) { + return decodeURIComponent( + (to.query?.redirect as string) || + userStore.userInfo?.homePath || + preferences.app.defaultHomePath, + ); + } + return true; + } + + // accessToken 检查 + if (!accessStore.accessToken) { + // 明确声明忽略权限访问权限,则可以访问 + if (to.meta.ignoreAccess) { + return true; + } + + // 没有访问权限,跳转登录页面 + if (to.fullPath !== LOGIN_PATH) { + return { + path: LOGIN_PATH, + // 如不需要,直接删除 query + query: + to.fullPath === preferences.app.defaultHomePath + ? {} + : { redirect: encodeURIComponent(to.fullPath) }, + // 携带当前跳转的页面,登录后重新跳转该页面 + replace: true, + }; + } + return to; + } + + // 是否已经生成过动态路由 + if (accessStore.isAccessChecked) { + return true; + } + + // 生成路由表 + // 当前登录用户拥有的角色标识列表 + const userInfo = userStore.userInfo || (await authStore.fetchUserInfo()); + const userRoles = userInfo.roles ?? []; + + // 生成菜单和路由 + const { accessibleMenus, accessibleRoutes } = await generateAccess({ + roles: userRoles, + router, + // 则会在菜单中显示,但是访问会被重定向到403 + routes: accessRoutes, + }); + + // 保存菜单信息和路由信息 + accessStore.setAccessMenus(accessibleMenus); + accessStore.setAccessRoutes(accessibleRoutes); + accessStore.setIsAccessChecked(true); + const redirectPath = (from.query.redirect ?? + (to.path === preferences.app.defaultHomePath + ? userInfo.homePath || preferences.app.defaultHomePath + : to.fullPath)) as string; + + return { + ...router.resolve(decodeURIComponent(redirectPath)), + replace: true, + }; + }); +} + +/** + * 项目守卫配置 + * @param router + */ +function createRouterGuard(router: Router) { + /** 通用 */ + setupCommonGuard(router); + /** 权限访问 */ + setupAccessGuard(router); +} + +export { createRouterGuard }; diff --git a/apps/web-finance/src/router/index.ts b/apps/web-finance/src/router/index.ts new file mode 100644 index 00000000..48402303 --- /dev/null +++ b/apps/web-finance/src/router/index.ts @@ -0,0 +1,37 @@ +import { + createRouter, + createWebHashHistory, + createWebHistory, +} from 'vue-router'; + +import { resetStaticRoutes } from '@vben/utils'; + +import { createRouterGuard } from './guard'; +import { routes } from './routes'; + +/** + * @zh_CN 创建vue-router实例 + */ +const router = createRouter({ + history: + import.meta.env.VITE_ROUTER_HISTORY === 'hash' + ? createWebHashHistory(import.meta.env.VITE_BASE) + : createWebHistory(import.meta.env.VITE_BASE), + // 应该添加到路由的初始路由列表。 + routes, + scrollBehavior: (to, _from, savedPosition) => { + if (savedPosition) { + return savedPosition; + } + return to.hash ? { behavior: 'smooth', el: to.hash } : { left: 0, top: 0 }; + }, + // 是否应该禁止尾部斜杠。 + // strict: true, +}); + +const resetRoutes = () => resetStaticRoutes(router, routes); + +// 创建路由守卫 +createRouterGuard(router); + +export { resetRoutes, router }; diff --git a/apps/web-finance/src/router/routes/core.ts b/apps/web-finance/src/router/routes/core.ts new file mode 100644 index 00000000..949b0b65 --- /dev/null +++ b/apps/web-finance/src/router/routes/core.ts @@ -0,0 +1,97 @@ +import type { RouteRecordRaw } from 'vue-router'; + +import { LOGIN_PATH } from '@vben/constants'; +import { preferences } from '@vben/preferences'; + +import { $t } from '#/locales'; + +const BasicLayout = () => import('#/layouts/basic.vue'); +const AuthPageLayout = () => import('#/layouts/auth.vue'); +/** 全局404页面 */ +const fallbackNotFoundRoute: RouteRecordRaw = { + component: () => import('#/views/_core/fallback/not-found.vue'), + meta: { + hideInBreadcrumb: true, + hideInMenu: true, + hideInTab: true, + title: '404', + }, + name: 'FallbackNotFound', + path: '/:path(.*)*', +}; + +/** 基本路由,这些路由是必须存在的 */ +const coreRoutes: RouteRecordRaw[] = [ + /** + * 根路由 + * 使用基础布局,作为所有页面的父级容器,子级就不必配置BasicLayout。 + * 此路由必须存在,且不应修改 + */ + { + component: BasicLayout, + meta: { + hideInBreadcrumb: true, + title: 'Root', + }, + name: 'Root', + path: '/', + redirect: preferences.app.defaultHomePath, + children: [], + }, + { + component: AuthPageLayout, + meta: { + hideInTab: true, + title: 'Authentication', + }, + name: 'Authentication', + path: '/auth', + redirect: LOGIN_PATH, + children: [ + { + name: 'Login', + path: 'login', + component: () => import('#/views/_core/authentication/login.vue'), + meta: { + title: $t('page.auth.login'), + }, + }, + { + name: 'CodeLogin', + path: 'code-login', + component: () => import('#/views/_core/authentication/code-login.vue'), + meta: { + title: $t('page.auth.codeLogin'), + }, + }, + { + name: 'QrCodeLogin', + path: 'qrcode-login', + component: () => + import('#/views/_core/authentication/qrcode-login.vue'), + meta: { + title: $t('page.auth.qrcodeLogin'), + }, + }, + { + name: 'ForgetPassword', + path: 'forget-password', + component: () => + import('#/views/_core/authentication/forget-password.vue'), + meta: { + title: $t('page.auth.forgetPassword'), + }, + }, + { + name: 'Register', + path: 'register', + component: () => import('#/views/_core/authentication/register.vue'), + meta: { + title: $t('page.auth.register'), + }, + }, + ], + }, +]; + +export { coreRoutes, fallbackNotFoundRoute }; diff --git a/apps/web-finance/src/router/routes/index.ts b/apps/web-finance/src/router/routes/index.ts new file mode 100644 index 00000000..e6fb1440 --- /dev/null +++ b/apps/web-finance/src/router/routes/index.ts @@ -0,0 +1,37 @@ +import type { RouteRecordRaw } from 'vue-router'; + +import { mergeRouteModules, traverseTreeValues } from '@vben/utils'; + +import { coreRoutes, fallbackNotFoundRoute } from './core'; + +const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', { + eager: true, +}); + +// 有需要可以自行打开注释,并创建文件夹 +// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true }); +// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true }); + +/** 动态路由 */ +const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles); + +/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */ +// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles); +// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles); +const staticRoutes: RouteRecordRaw[] = []; +const externalRoutes: RouteRecordRaw[] = []; + +/** 路由列表,由基本路由、外部路由和404兜底路由组成 + * 无需走权限验证(会一直显示在菜单中) */ +const routes: RouteRecordRaw[] = [ + ...coreRoutes, + ...externalRoutes, + fallbackNotFoundRoute, +]; + +/** 基本路由列表,这些路由不需要进入权限拦截 */ +const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name); + +/** 有权限校验的路由列表,包含动态路由和静态路由 */ +const accessRoutes = [...dynamicRoutes, ...staticRoutes]; +export { accessRoutes, coreRouteNames, routes }; diff --git a/apps/web-finance/src/router/routes/modules/analytics.ts b/apps/web-finance/src/router/routes/modules/analytics.ts new file mode 100644 index 00000000..6634329a --- /dev/null +++ b/apps/web-finance/src/router/routes/modules/analytics.ts @@ -0,0 +1,81 @@ +import type { RouteRecordRaw } from 'vue-router'; + +import { BasicLayout } from '#/layouts'; +import { $t } from '#/locales'; + +const routes: RouteRecordRaw[] = [ + { + component: BasicLayout, + meta: { + icon: 'ant-design:bar-chart-outlined', + order: 2, + title: $t('analytics.title'), + }, + name: 'Analytics', + path: '/analytics', + children: [ + { + meta: { + icon: 'ant-design:dashboard-outlined', + title: $t('analytics.overview'), + }, + name: 'AnalyticsOverview', + path: 'overview', + component: () => import('#/views/analytics/overview/index.vue'), + }, + { + meta: { + icon: 'ant-design:line-chart-outlined', + title: $t('analytics.trends'), + }, + name: 'AnalyticsTrends', + path: 'trends', + component: () => import('#/views/analytics/trends/index.vue'), + }, + { + meta: { + icon: 'ant-design:file-text-outlined', + title: $t('analytics.reports'), + }, + name: 'AnalyticsReports', + path: 'reports', + children: [ + { + meta: { + title: $t('analytics.reports.daily'), + }, + name: 'DailyReport', + path: 'daily', + component: () => import('#/views/analytics/reports/daily.vue'), + }, + { + meta: { + title: $t('analytics.reports.monthly'), + }, + name: 'MonthlyReport', + path: 'monthly', + component: () => import('#/views/analytics/reports/monthly.vue'), + }, + { + meta: { + title: $t('analytics.reports.yearly'), + }, + name: 'YearlyReport', + path: 'yearly', + component: () => import('#/views/analytics/reports/yearly.vue'), + }, + { + meta: { + title: $t('analytics.reports.custom'), + }, + name: 'CustomReport', + path: 'custom', + component: () => import('#/views/analytics/reports/custom.vue'), + }, + ], + }, + ], + }, +]; + +export default routes; \ No newline at end of file diff --git a/apps/web-finance/src/router/routes/modules/dashboard.ts b/apps/web-finance/src/router/routes/modules/dashboard.ts new file mode 100644 index 00000000..5254dc65 --- /dev/null +++ b/apps/web-finance/src/router/routes/modules/dashboard.ts @@ -0,0 +1,38 @@ +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'), + }, + }, + ], + }, +]; + +export default routes; diff --git a/apps/web-finance/src/router/routes/modules/demos.ts b/apps/web-finance/src/router/routes/modules/demos.ts new file mode 100644 index 00000000..55ade09c --- /dev/null +++ b/apps/web-finance/src/router/routes/modules/demos.ts @@ -0,0 +1,28 @@ +import type { RouteRecordRaw } from 'vue-router'; + +import { $t } from '#/locales'; + +const routes: RouteRecordRaw[] = [ + { + meta: { + icon: 'ic:baseline-view-in-ar', + keepAlive: true, + order: 1000, + title: $t('demos.title'), + }, + name: 'Demos', + path: '/demos', + children: [ + { + meta: { + title: $t('demos.antd'), + }, + name: 'AntDesignDemos', + path: '/demos/ant-design', + component: () => import('#/views/demos/antd/index.vue'), + }, + ], + }, +]; + +export default routes; diff --git a/apps/web-finance/src/router/routes/modules/finance.ts b/apps/web-finance/src/router/routes/modules/finance.ts new file mode 100644 index 00000000..e96a8c3b --- /dev/null +++ b/apps/web-finance/src/router/routes/modules/finance.ts @@ -0,0 +1,103 @@ +import type { RouteRecordRaw } from 'vue-router'; + +import { BasicLayout } from '#/layouts'; +import { $t } from '#/locales'; + +const routes: RouteRecordRaw[] = [ + { + component: BasicLayout, + meta: { + icon: 'ant-design:dollar-outlined', + order: 1, + title: $t('finance.title'), + }, + name: 'Finance', + path: '/finance', + children: [ + { + meta: { + icon: 'ant-design:home-outlined', + title: $t('finance.dashboard'), + }, + name: 'FinanceDashboard', + path: 'dashboard', + component: () => import('#/views/finance/dashboard/index.vue'), + }, + { + meta: { + icon: 'ant-design:swap-outlined', + title: $t('finance.transaction'), + }, + name: 'Transaction', + path: 'transaction', + component: () => import('#/views/finance/transaction/index.vue'), + }, + { + meta: { + icon: 'ant-design:appstore-outlined', + title: $t('finance.category'), + }, + name: 'Category', + path: 'category', + component: () => import('#/views/finance/category/index.vue'), + }, + { + meta: { + icon: 'ant-design:team-outlined', + title: $t('finance.person'), + }, + name: 'Person', + path: 'person', + component: () => import('#/views/finance/person/index.vue'), + }, + { + meta: { + icon: 'ant-design:bank-outlined', + title: $t('finance.loan'), + }, + name: 'Loan', + path: 'loan', + component: () => import('#/views/finance/loan/index.vue'), + }, + { + meta: { + icon: 'ant-design:tag-outlined', + title: $t('finance.tag'), + }, + name: 'Tag', + path: 'tag', + component: () => import('#/views/finance/tag/index.vue'), + }, + { + meta: { + icon: 'ant-design:wallet-outlined', + title: $t('finance.budget'), + }, + name: 'Budget', + path: 'budget', + component: () => import('#/views/finance/budget/index.vue'), + }, + { + meta: { + icon: 'ant-design:mobile-outlined', + title: $t('finance.mobile'), + hideInMenu: true, // 在桌面端菜单中隐藏 + }, + name: 'MobileFinance', + path: 'mobile', + component: () => import('#/views/finance/mobile/index.vue'), + }, + { + meta: { + icon: 'ant-design:bug-outlined', + title: 'API测试', + }, + name: 'TestAPI', + path: 'test-api', + component: () => import('#/views/finance/test-api.vue'), + }, + ], + }, +]; + +export default routes; \ No newline at end of file diff --git a/apps/web-finance/src/router/routes/modules/tools.ts b/apps/web-finance/src/router/routes/modules/tools.ts new file mode 100644 index 00000000..83cbc0b5 --- /dev/null +++ b/apps/web-finance/src/router/routes/modules/tools.ts @@ -0,0 +1,66 @@ +import type { RouteRecordRaw } from 'vue-router'; + +import { BasicLayout } from '#/layouts'; +import { $t } from '#/locales'; + +const routes: RouteRecordRaw[] = [ + { + component: BasicLayout, + meta: { + icon: 'ant-design:tool-outlined', + order: 3, + title: $t('tools.title'), + }, + name: 'Tools', + path: '/tools', + children: [ + { + meta: { + icon: 'ant-design:import-outlined', + title: $t('tools.import'), + }, + name: 'DataImport', + path: 'import', + component: () => import('#/views/tools/import/index.vue'), + }, + { + meta: { + icon: 'ant-design:export-outlined', + title: $t('tools.export'), + }, + name: 'DataExport', + path: 'export', + component: () => import('#/views/tools/export/index.vue'), + }, + { + meta: { + icon: 'ant-design:database-outlined', + title: $t('tools.backup'), + }, + name: 'DataBackup', + path: 'backup', + component: () => import('#/views/tools/backup/index.vue'), + }, + { + meta: { + icon: 'ant-design:calculator-outlined', + title: $t('tools.budget'), + }, + name: 'BudgetManagement', + path: 'budget', + component: () => import('#/views/tools/budget/index.vue'), + }, + { + meta: { + icon: 'ant-design:tags-outlined', + title: $t('tools.tags'), + }, + name: 'TagManagement', + path: 'tags', + component: () => import('#/views/tools/tags/index.vue'), + }, + ], + }, +]; + +export default routes; \ No newline at end of file diff --git a/apps/web-finance/src/router/routes/modules/vben.ts b/apps/web-finance/src/router/routes/modules/vben.ts new file mode 100644 index 00000000..98acf582 --- /dev/null +++ b/apps/web-finance/src/router/routes/modules/vben.ts @@ -0,0 +1,81 @@ +import type { RouteRecordRaw } from 'vue-router'; + +import { + VBEN_DOC_URL, + VBEN_ELE_PREVIEW_URL, + VBEN_GITHUB_URL, + VBEN_LOGO_URL, + VBEN_NAIVE_PREVIEW_URL, +} from '@vben/constants'; + +import { IFrameView } from '#/layouts'; +import { $t } from '#/locales'; + +const routes: RouteRecordRaw[] = [ + { + meta: { + badgeType: 'dot', + icon: VBEN_LOGO_URL, + order: 9998, + title: $t('demos.vben.title'), + }, + name: 'VbenProject', + path: '/vben-admin', + children: [ + { + name: 'VbenDocument', + path: '/vben-admin/document', + component: IFrameView, + meta: { + icon: 'lucide:book-open-text', + link: VBEN_DOC_URL, + title: $t('demos.vben.document'), + }, + }, + { + name: 'VbenGithub', + path: '/vben-admin/github', + component: IFrameView, + meta: { + icon: 'mdi:github', + link: VBEN_GITHUB_URL, + title: 'Github', + }, + }, + { + name: 'VbenNaive', + path: '/vben-admin/naive', + component: IFrameView, + meta: { + badgeType: 'dot', + icon: 'logos:naiveui', + link: VBEN_NAIVE_PREVIEW_URL, + title: $t('demos.vben.naive-ui'), + }, + }, + { + name: 'VbenElementPlus', + path: '/vben-admin/ele', + component: IFrameView, + meta: { + badgeType: 'dot', + icon: 'logos:element', + link: VBEN_ELE_PREVIEW_URL, + title: $t('demos.vben.element-plus'), + }, + }, + ], + }, + { + name: 'VbenAbout', + path: '/vben-admin/about', + component: () => import('#/views/_core/about/index.vue'), + meta: { + icon: 'lucide:copyright', + title: $t('demos.vben.about'), + order: 9999, + }, + }, +]; + +export default routes; diff --git a/apps/web-finance/src/store/auth.ts b/apps/web-finance/src/store/auth.ts new file mode 100644 index 00000000..bd496d1e --- /dev/null +++ b/apps/web-finance/src/store/auth.ts @@ -0,0 +1,118 @@ +import type { Recordable, UserInfo } from '@vben/types'; + +import { ref } from 'vue'; +import { useRouter } from 'vue-router'; + +import { LOGIN_PATH } from '@vben/constants'; +import { preferences } from '@vben/preferences'; +import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores'; + +import { notification } from 'ant-design-vue'; +import { defineStore } from 'pinia'; + +import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api'; +import { $t } from '#/locales'; + +export const useAuthStore = defineStore('auth', () => { + const accessStore = useAccessStore(); + const userStore = useUserStore(); + const router = useRouter(); + + const loginLoading = ref(false); + + /** + * 异步处理登录操作 + * Asynchronously handle the login process + * @param params 登录表单数据 + */ + async function authLogin( + params: Recordable, + onSuccess?: () => Promise | void, + ) { + // 异步处理用户登录操作并获取 accessToken + let userInfo: null | UserInfo = null; + try { + loginLoading.value = true; + const { accessToken } = await loginApi(params); + + // 如果成功获取到 accessToken + if (accessToken) { + accessStore.setAccessToken(accessToken); + + // 获取用户信息并存储到 accessStore 中 + const [fetchUserInfoResult, accessCodes] = await Promise.all([ + fetchUserInfo(), + getAccessCodesApi(), + ]); + + userInfo = fetchUserInfoResult; + + userStore.setUserInfo(userInfo); + accessStore.setAccessCodes(accessCodes); + + if (accessStore.loginExpired) { + accessStore.setLoginExpired(false); + } else { + onSuccess + ? await onSuccess?.() + : await router.push( + userInfo.homePath || preferences.app.defaultHomePath, + ); + } + + if (userInfo?.realName) { + notification.success({ + description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`, + duration: 3, + message: $t('authentication.loginSuccess'), + }); + } + } + } finally { + loginLoading.value = false; + } + + return { + userInfo, + }; + } + + async function logout(redirect: boolean = true) { + try { + await logoutApi(); + } catch { + // 不做任何处理 + } + resetAllStores(); + accessStore.setLoginExpired(false); + + // 回登录页带上当前路由地址 + await router.replace({ + path: LOGIN_PATH, + query: redirect + ? { + redirect: encodeURIComponent(router.currentRoute.value.fullPath), + } + : {}, + }); + } + + async function fetchUserInfo() { + let userInfo: null | UserInfo = null; + userInfo = await getUserInfoApi(); + userStore.setUserInfo(userInfo); + return userInfo; + } + + function $reset() { + loginLoading.value = false; + } + + return { + $reset, + authLogin, + fetchUserInfo, + loginLoading, + logout, + }; +}); diff --git a/apps/web-finance/src/store/index.ts b/apps/web-finance/src/store/index.ts new file mode 100644 index 00000000..269586ee --- /dev/null +++ b/apps/web-finance/src/store/index.ts @@ -0,0 +1 @@ +export * from './auth'; diff --git a/apps/web-finance/src/store/modules/budget.ts b/apps/web-finance/src/store/modules/budget.ts new file mode 100644 index 00000000..9c3e1d4e --- /dev/null +++ b/apps/web-finance/src/store/modules/budget.ts @@ -0,0 +1,166 @@ +import type { Budget, BudgetStats, Transaction } from '#/types/finance'; + +import dayjs from 'dayjs'; +import { defineStore } from 'pinia'; + +import { add, remove, getAll, update, STORES } from '#/utils/db'; + +interface BudgetState { + budgets: Budget[]; + loading: boolean; +} + +export const useBudgetStore = defineStore('budget', { + state: (): BudgetState => ({ + budgets: [], + loading: false, + }), + + getters: { + // 获取当前月份的预算 + currentMonthBudgets: (state) => { + const now = dayjs(); + const year = now.year(); + const month = now.month() + 1; + + return state.budgets.filter(b => + b.year === year && + (b.period === 'yearly' || (b.period === 'monthly' && b.month === month)) + ); + }, + + // 获取指定分类的当前预算 + getCategoryBudget: (state) => (categoryId: string) => { + const now = dayjs(); + const year = now.year(); + const month = now.month() + 1; + + return state.budgets.find(b => + b.categoryId === categoryId && + b.year === year && + (b.period === 'yearly' || (b.period === 'monthly' && b.month === month)) + ); + }, + }, + + actions: { + // 获取所有预算 + async fetchBudgets() { + this.loading = true; + try { + const budgets = await getAll(STORES.BUDGETS); + this.budgets = budgets; + } catch (error) { + console.error('获取预算失败:', error); + } finally { + this.loading = false; + } + }, + + // 创建预算 + async createBudget(budget: Partial) { + try { + const newBudget: Budget = { + id: crypto.randomUUID(), + categoryId: budget.categoryId || '', + amount: budget.amount || 0, + currency: budget.currency || 'CNY', + period: budget.period || 'monthly', + year: budget.year || dayjs().year(), + month: budget.month, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + await add(STORES.BUDGETS, newBudget); + this.budgets.push(newBudget); + return newBudget; + } catch (error) { + console.error('创建预算失败:', error); + throw error; + } + }, + + // 更新预算 + async updateBudget(id: string, updates: Partial) { + try { + const index = this.budgets.findIndex(b => b.id === id); + if (index === -1) throw new Error('预算不存在'); + + const updatedBudget = { + ...this.budgets[index], + ...updates, + updated_at: new Date().toISOString(), + }; + + await update(STORES.BUDGETS, updatedBudget); + this.budgets[index] = updatedBudget; + return updatedBudget; + } catch (error) { + console.error('更新预算失败:', error); + throw error; + } + }, + + // 删除预算 + async deleteBudget(id: string) { + try { + await remove(STORES.BUDGETS, id); + const index = this.budgets.findIndex(b => b.id === id); + if (index > -1) { + this.budgets.splice(index, 1); + } + } catch (error) { + console.error('删除预算失败:', error); + throw error; + } + }, + + // 计算预算统计 + calculateBudgetStats(budget: Budget, transactions: Transaction[]): BudgetStats { + // 过滤出属于该预算期间的交易 + let filteredTransactions: Transaction[] = []; + + if (budget.period === 'monthly') { + filteredTransactions = transactions.filter(t => { + const date = dayjs(t.date); + return t.type === 'expense' && + t.categoryId === budget.categoryId && + date.year() === budget.year && + date.month() + 1 === budget.month; + }); + } else { + // 年度预算 + filteredTransactions = transactions.filter(t => { + const date = dayjs(t.date); + return t.type === 'expense' && + t.categoryId === budget.categoryId && + date.year() === budget.year; + }); + } + + // 计算已花费金额 + const spent = filteredTransactions.reduce((sum, t) => sum + t.amount, 0); + const remaining = budget.amount - spent; + const percentage = budget.amount > 0 ? (spent / budget.amount) * 100 : 0; + + return { + budget, + spent, + remaining, + percentage: Math.round(percentage), + transactions: filteredTransactions.length, + }; + }, + + // 检查是否存在相同的预算 + isBudgetExists(categoryId: string, year: number, period: 'monthly' | 'yearly', month?: number): boolean { + return this.budgets.some(b => + b.categoryId === categoryId && + b.year === year && + b.period === period && + (period === 'yearly' || b.month === month) + ); + }, + }, +}); \ No newline at end of file diff --git a/apps/web-finance/src/store/modules/category.ts b/apps/web-finance/src/store/modules/category.ts new file mode 100644 index 00000000..31b9d825 --- /dev/null +++ b/apps/web-finance/src/store/modules/category.ts @@ -0,0 +1,93 @@ +import type { Category } from '#/types/finance'; + +import { computed, ref } from 'vue'; + +import { defineStore } from 'pinia'; + +import { + createCategory as createCategoryApi, + deleteCategory as deleteCategoryApi, + getCategoryList, + getCategoryTree, + updateCategory as updateCategoryApi, +} from '#/api/finance'; + +export const useCategoryStore = defineStore('finance-category', () => { + // 状态 + const categories = ref([]); + const categoryTree = ref([]); + const loading = ref(false); + + // 计算属性 + const incomeCategories = computed(() => + categories.value.filter((c) => c.type === 'income'), + ); + + const expenseCategories = computed(() => + categories.value.filter((c) => c.type === 'expense'), + ); + + // 获取分类列表 + async function fetchCategories() { + loading.value = true; + try { + const { items } = await getCategoryList(); + categories.value = items; + } finally { + loading.value = false; + } + } + + // 获取分类树 + async function fetchCategoryTree() { + loading.value = true; + try { + const data = await getCategoryTree(); + categoryTree.value = data; + } finally { + loading.value = false; + } + } + + // 创建分类 + async function createCategory(data: Partial) { + const newCategory = await createCategoryApi(data); + categories.value.push(newCategory); + return newCategory; + } + + // 更新分类 + async function updateCategory(id: string, data: Partial) { + const updatedCategory = await updateCategoryApi(id, data); + const index = categories.value.findIndex((c) => c.id === id); + if (index !== -1) { + categories.value[index] = updatedCategory; + } + return updatedCategory; + } + + // 删除分类 + async function deleteCategory(id: string) { + await deleteCategoryApi(id); + categories.value = categories.value.filter((c) => c.id !== id); + } + + // 根据ID获取分类 + function getCategoryById(id: string) { + return categories.value.find((c) => c.id === id); + } + + return { + categories, + categoryTree, + loading, + incomeCategories, + expenseCategories, + fetchCategories, + fetchCategoryTree, + createCategory, + updateCategory, + deleteCategory, + getCategoryById, + }; +}); \ No newline at end of file diff --git a/apps/web-finance/src/store/modules/loan.ts b/apps/web-finance/src/store/modules/loan.ts new file mode 100644 index 00000000..cdfc478b --- /dev/null +++ b/apps/web-finance/src/store/modules/loan.ts @@ -0,0 +1,142 @@ +import type { + Loan, + LoanRepayment, + LoanStatus, + SearchParams +} from '#/types/finance'; + +import { computed, ref } from 'vue'; + +import { defineStore } from 'pinia'; + +import { + addLoanRepayment as addRepaymentApi, + createLoan as createLoanApi, + deleteLoan as deleteLoanApi, + getLoanList, + getLoanStatistics, + updateLoan as updateLoanApi, + updateLoanStatus as updateStatusApi, +} from '#/api/finance'; + +export const useLoanStore = defineStore('finance-loan', () => { + // 状态 + const loans = ref([]); + const loading = ref(false); + const statistics = ref({ + totalLent: 0, + totalBorrowed: 0, + totalRepaid: 0, + activeLoans: 0, + overdueLoans: 0, + }); + + // 计算属性 + const activeLoans = computed(() => + loans.value.filter((loan) => loan.status === 'active'), + ); + + const overdueLoans = computed(() => + loans.value.filter((loan) => loan.status === 'overdue'), + ); + + const paidLoans = computed(() => + loans.value.filter((loan) => loan.status === 'paid'), + ); + + // 获取贷款列表 + async function fetchLoans(params: SearchParams) { + loading.value = true; + try { + const { items } = await getLoanList(params); + loans.value = items; + return items; + } finally { + loading.value = false; + } + } + + // 获取贷款统计 + async function fetchStatistics() { + const data = await getLoanStatistics(); + statistics.value = data; + return data; + } + + // 创建贷款 + async function createLoan(data: Partial) { + const newLoan = await createLoanApi(data); + loans.value.push(newLoan); + return newLoan; + } + + // 更新贷款 + async function updateLoan(id: string, data: Partial) { + const updatedLoan = await updateLoanApi(id, data); + const index = loans.value.findIndex((l) => l.id === id); + if (index !== -1) { + loans.value[index] = updatedLoan; + } + return updatedLoan; + } + + // 删除贷款 + async function deleteLoan(id: string) { + await deleteLoanApi(id); + loans.value = loans.value.filter((l) => l.id !== id); + } + + // 添加还款记录 + async function addRepayment(loanId: string, repayment: Partial) { + const updatedLoan = await addRepaymentApi(loanId, repayment); + const index = loans.value.findIndex((l) => l.id === loanId); + if (index !== -1) { + loans.value[index] = updatedLoan; + } + return updatedLoan; + } + + // 更新贷款状态 + async function updateLoanStatus(id: string, status: LoanStatus) { + const updatedLoan = await updateStatusApi(id, status); + const index = loans.value.findIndex((l) => l.id === id); + if (index !== -1) { + loans.value[index] = updatedLoan; + } + return updatedLoan; + } + + // 根据ID获取贷款 + function getLoanById(id: string) { + return loans.value.find((l) => l.id === id); + } + + // 根据借款人获取贷款 + function getLoansByBorrower(borrower: string) { + return loans.value.filter((l) => l.borrower === borrower); + } + + // 根据出借人获取贷款 + function getLoansByLender(lender: string) { + return loans.value.filter((l) => l.lender === lender); + } + + return { + loans, + loading, + statistics, + activeLoans, + overdueLoans, + paidLoans, + fetchLoans, + fetchStatistics, + createLoan, + updateLoan, + deleteLoan, + addRepayment, + updateLoanStatus, + getLoanById, + getLoansByBorrower, + getLoansByLender, + }; +}); \ No newline at end of file diff --git a/apps/web-finance/src/store/modules/person.ts b/apps/web-finance/src/store/modules/person.ts new file mode 100644 index 00000000..a3936770 --- /dev/null +++ b/apps/web-finance/src/store/modules/person.ts @@ -0,0 +1,91 @@ +import type { Person } from '#/types/finance'; + +import { ref } from 'vue'; + +import { defineStore } from 'pinia'; + +import { + createPerson as createPersonApi, + deletePerson as deletePersonApi, + getPersonList, + searchPersons as searchPersonsApi, + updatePerson as updatePersonApi, +} from '#/api/finance'; + +export const usePersonStore = defineStore('finance-person', () => { + // 状态 + const persons = ref([]); + const loading = ref(false); + + // 获取人员列表 + async function fetchPersons(params?: { page?: number; pageSize?: number }) { + loading.value = true; + try { + const { items } = await getPersonList(params); + persons.value = items; + return items; + } finally { + loading.value = false; + } + } + + // 搜索人员 + async function searchPersons(keyword: string) { + if (!keyword) { + return persons.value; + } + const results = await searchPersonsApi(keyword); + return results; + } + + // 创建人员 + async function createPerson(data: Partial) { + const newPerson = await createPersonApi(data); + persons.value.push(newPerson); + return newPerson; + } + + // 更新人员 + async function updatePerson(id: string, data: Partial) { + const updatedPerson = await updatePersonApi(id, data); + const index = persons.value.findIndex((p) => p.id === id); + if (index !== -1) { + persons.value[index] = updatedPerson; + } + return updatedPerson; + } + + // 删除人员 + async function deletePerson(id: string) { + await deletePersonApi(id); + persons.value = persons.value.filter((p) => p.id !== id); + } + + // 根据ID获取人员 + function getPersonById(id: string) { + return persons.value.find((p) => p.id === id); + } + + // 根据名称获取人员 + function getPersonByName(name: string) { + return persons.value.find((p) => p.name === name); + } + + // 根据角色筛选人员 + function getPersonsByRole(role: Person['roles'][number]) { + return persons.value.filter((p) => p.roles.includes(role)); + } + + return { + persons, + loading, + fetchPersons, + searchPersons, + createPerson, + updatePerson, + deletePerson, + getPersonById, + getPersonByName, + getPersonsByRole, + }; +}); \ No newline at end of file diff --git a/apps/web-finance/src/store/modules/tag.ts b/apps/web-finance/src/store/modules/tag.ts new file mode 100644 index 00000000..5bc40f02 --- /dev/null +++ b/apps/web-finance/src/store/modules/tag.ts @@ -0,0 +1,120 @@ +import type { Tag } from '#/types/finance'; + +import { defineStore } from 'pinia'; + +import { add, remove, getAll, update, STORES } from '#/utils/db'; + +interface TagState { + tags: Tag[]; + loading: boolean; +} + +export const useTagStore = defineStore('tag', { + state: (): TagState => ({ + tags: [], + loading: false, + }), + + getters: { + // 按名称排序的标签 + sortedTags: (state) => { + return [...state.tags].sort((a, b) => a.name.localeCompare(b.name)); + }, + + // 获取标签映射 + tagMap: (state) => { + return new Map(state.tags.map(tag => [tag.id, tag])); + }, + }, + + actions: { + // 获取所有标签 + async fetchTags() { + this.loading = true; + try { + const tags = await getAll(STORES.TAGS); + this.tags = tags; + } catch (error) { + console.error('获取标签失败:', error); + } finally { + this.loading = false; + } + }, + + // 创建标签 + async createTag(tag: Partial) { + try { + const newTag: Tag = { + id: crypto.randomUUID(), + name: tag.name || '', + color: tag.color || '#1890ff', + description: tag.description, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + await add(STORES.TAGS, newTag); + this.tags.push(newTag); + return newTag; + } catch (error) { + console.error('创建标签失败:', error); + throw error; + } + }, + + // 更新标签 + async updateTag(id: string, updates: Partial) { + try { + const index = this.tags.findIndex(t => t.id === id); + if (index === -1) throw new Error('标签不存在'); + + const updatedTag = { + ...this.tags[index], + ...updates, + updated_at: new Date().toISOString(), + }; + + await update(STORES.TAGS, updatedTag); + this.tags[index] = updatedTag; + return updatedTag; + } catch (error) { + console.error('更新标签失败:', error); + throw error; + } + }, + + // 删除标签 + async deleteTag(id: string) { + try { + await remove(STORES.TAGS, id); + const index = this.tags.findIndex(t => t.id === id); + if (index > -1) { + this.tags.splice(index, 1); + } + } catch (error) { + console.error('删除标签失败:', error); + throw error; + } + }, + + // 批量删除标签 + async deleteTags(ids: string[]) { + try { + for (const id of ids) { + await remove(STORES.TAGS, id); + } + this.tags = this.tags.filter(t => !ids.includes(t.id)); + } catch (error) { + console.error('批量删除标签失败:', error); + throw error; + } + }, + + // 检查标签名称是否已存在 + isTagNameExists(name: string, excludeId?: string): boolean { + return this.tags.some(t => + t.name === name && t.id !== excludeId + ); + }, + }, +}); \ No newline at end of file diff --git a/apps/web-finance/src/store/modules/transaction.ts b/apps/web-finance/src/store/modules/transaction.ts new file mode 100644 index 00000000..a49d55a8 --- /dev/null +++ b/apps/web-finance/src/store/modules/transaction.ts @@ -0,0 +1,141 @@ +import type { + ExportParams, + ImportResult, + PageResult, + SearchParams, + Transaction +} from '#/types/finance'; + +import { ref } from 'vue'; + +import { defineStore } from 'pinia'; + +import { + batchDeleteTransactions as batchDeleteApi, + createTransaction as createTransactionApi, + deleteTransaction as deleteTransactionApi, + exportTransactions as exportTransactionsApi, + getTransactionList, + getTransactionStatistics, + importTransactions as importTransactionsApi, + updateTransaction as updateTransactionApi, +} from '#/api/finance'; + +export const useTransactionStore = defineStore('finance-transaction', () => { + // 状态 + const transactions = ref([]); + const currentTransaction = ref(null); + const loading = ref(false); + const pageInfo = ref({ + total: 0, + page: 1, + pageSize: 20, + totalPages: 0, + }); + const statistics = ref({ + totalIncome: 0, + totalExpense: 0, + balance: 0, + }); + + // 获取交易列表 + async function fetchTransactions(params: SearchParams) { + loading.value = true; + try { + const result = await getTransactionList(params); + transactions.value = result.items; + pageInfo.value = { + total: result.total, + page: result.page, + pageSize: result.pageSize, + totalPages: result.totalPages, + }; + return result; + } finally { + loading.value = false; + } + } + + // 获取统计数据 + async function fetchStatistics(params?: SearchParams) { + const data = await getTransactionStatistics(params); + statistics.value = data; + return data; + } + + // 创建交易 + async function createTransaction(data: Partial) { + const newTransaction = await createTransactionApi(data); + transactions.value.unshift(newTransaction); + return newTransaction; + } + + // 更新交易 + async function updateTransaction(id: string, data: Partial) { + const updatedTransaction = await updateTransactionApi(id, data); + const index = transactions.value.findIndex((t) => t.id === id); + if (index !== -1) { + transactions.value[index] = updatedTransaction; + } + return updatedTransaction; + } + + // 删除交易 + async function deleteTransaction(id: string) { + await deleteTransactionApi(id); + transactions.value = transactions.value.filter((t) => t.id !== id); + } + + // 批量删除交易 + async function batchDeleteTransactions(ids: string[]) { + await batchDeleteApi(ids); + transactions.value = transactions.value.filter((t) => !ids.includes(t.id)); + } + + // 导出交易 + async function exportTransactions(params: ExportParams) { + const blob = await exportTransactionsApi(params); + // 创建下载链接 + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `transactions_${Date.now()}.${params.format}`; + link.click(); + window.URL.revokeObjectURL(url); + } + + // 导入交易 + async function importTransactions(file: File): Promise { + loading.value = true; + try { + const result = await importTransactionsApi(file); + // 导入成功后刷新列表 + await fetchTransactions({ page: 1, pageSize: 20 }); + return result; + } finally { + loading.value = false; + } + } + + // 设置当前交易 + function setCurrentTransaction(transaction: Transaction | null) { + currentTransaction.value = transaction; + } + + return { + transactions, + currentTransaction, + loading, + pageInfo, + statistics, + fetchTransactions, + fetchStatistics, + createTransaction, + updateTransaction, + deleteTransaction, + batchDeleteTransactions, + exportTransactions, + importTransactions, + setCurrentTransaction, + }; +}); \ No newline at end of file diff --git a/apps/web-finance/src/styles/mobile.css b/apps/web-finance/src/styles/mobile.css new file mode 100644 index 00000000..a26f97de --- /dev/null +++ b/apps/web-finance/src/styles/mobile.css @@ -0,0 +1,150 @@ +/* 移动端全局样式优化 */ + +/* 防止iOS橡皮筋效果 */ +@media (max-width: 768px) { + html, + body { + overscroll-behavior: none; + -webkit-overflow-scrolling: touch; + } + + /* 移除桌面端的侧边栏和顶部导航 */ + .vben-layout-sidebar, + .vben-layout-header { + display: none !important; + } + + /* 移动端内容区域全屏 */ + .vben-layout-content { + margin: 0 !important; + padding: 0 !important; + height: 100vh !important; + } + + /* 优化点击效果 */ + * { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + } + + /* 优化输入框 */ + input, + textarea, + select { + font-size: 16px !important; /* 防止iOS自动缩放 */ + -webkit-appearance: none; + } + + /* 优化按钮点击 */ + button, + .ant-btn { + touch-action: manipulation; + } + + /* 优化模态框和抽屉 */ + .ant-modal { + max-width: calc(100vw - 32px); + } + + .ant-drawer-content-wrapper { + border-top-left-radius: 12px; + border-top-right-radius: 12px; + } + + /* 优化表单项间距 */ + .ant-form-item { + margin-bottom: 16px; + } + + /* 优化列表项 */ + .ant-list-item { + padding: 12px; + } + + /* 优化卡片间距 */ + .ant-card { + margin-bottom: 12px; + } + + /* 移动端安全区域适配 */ + .mobile-finance, + .mobile-quick-add, + .mobile-transaction-list, + .mobile-statistics, + .mobile-budget, + .mobile-more { + padding-bottom: env(safe-area-inset-bottom); + } + + /* 浮动按钮安全区域适配 */ + .floating-button { + bottom: calc(20px + env(safe-area-inset-bottom)) !important; + } + + /* 底部标签栏安全区域适配 */ + .mobile-tabs .ant-tabs-nav { + padding-bottom: env(safe-area-inset-bottom); + } +} + +/* 横屏优化 */ +@media (max-width: 768px) and (orientation: landscape) { + .mobile-quick-add .category-grid { + grid-template-columns: repeat(5, 1fr); + } + + .mobile-statistics .overview-cards { + grid-template-columns: repeat(3, 1fr); + } +} + +/* 小屏幕手机优化 */ +@media (max-width: 375px) { + .mobile-quick-add .category-grid { + grid-template-columns: repeat(3, 1fr); + gap: 8px; + } + + .mobile-statistics .overview-cards { + grid-template-columns: 1fr; + gap: 8px; + } + + .mobile-budget .budget-summary { + flex-direction: column; + text-align: center; + } +} + +/* 动画优化 */ +@media (max-width: 768px) { + /* 减少动画时间 */ + * { + animation-duration: 0.2s !important; + transition-duration: 0.2s !important; + } + + /* 禁用复杂动画 */ + .ant-progress-circle { + animation: none !important; + } +} + +/* 触摸优化 */ +@media (pointer: coarse) { + /* 增大可点击区域 */ + .ant-btn, + .menu-item, + .category-item, + .transaction-item { + min-height: 44px; + } + + /* 增大关闭按钮 */ + .ant-modal-close, + .ant-drawer-close { + width: 44px; + height: 44px; + line-height: 44px; + } +} \ No newline at end of file diff --git a/apps/web-finance/src/types/finance.ts b/apps/web-finance/src/types/finance.ts new file mode 100644 index 00000000..9bfe095a --- /dev/null +++ b/apps/web-finance/src/types/finance.ts @@ -0,0 +1,175 @@ +// 财务管理系统类型定义 + +// 货币类型 +export type Currency = 'USD' | 'CNY' | 'THB' | 'MMK'; + +// 交易类型 +export type TransactionType = 'income' | 'expense'; + +// 人员角色 +export type PersonRole = 'payer' | 'payee' | 'borrower' | 'lender'; + +// 贷款状态 +export type LoanStatus = 'active' | 'paid' | 'overdue'; + +// 交易状态 +export type TransactionStatus = 'pending' | 'completed' | 'cancelled'; + +// 分类 +export interface Category { + id: string; + name: string; + type: TransactionType; + parentId?: string; + icon?: string; + color?: string; + budget?: number; // 月度预算 + created_at: string; + updated_at?: string; +} + +// 人员 +export interface Person { + id: string; + name: string; + roles: PersonRole[]; + contact?: string; + description?: string; + created_at: string; + updated_at?: string; +} + +// 交易 +export interface Transaction { + id: string; + amount: number; + type: TransactionType; + categoryId: string; + description?: string; + date: string; + quantity?: number; + project?: string; + payer?: string; + payee?: string; + recorder?: string; + currency: Currency; + status: TransactionStatus; + tags?: string[]; // 标签 + created_at: string; + updated_at?: string; +} + +// 还款记录 +export interface LoanRepayment { + id: string; + amount: number; + currency: Currency; + date: string; + note?: string; +} + +// 贷款 +export interface Loan { + id: string; + borrower: string; + lender: string; + amount: number; + currency: Currency; + startDate: string; + dueDate?: string; + description?: string; + status: LoanStatus; + repayments: LoanRepayment[]; + created_at: string; + updated_at?: string; +} + +// 统计数据 +export interface Statistics { + totalIncome: number; + totalExpense: number; + balance: number; + currency: Currency; + period?: { + start: string; + end: string; + }; +} + +// 分页参数 +export interface PageParams { + page: number; + pageSize: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +// 分页结果 +export interface PageResult { + items: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +// 搜索参数 +export interface SearchParams extends PageParams { + keyword?: string; + type?: TransactionType; + categoryId?: string; + personId?: string; + currency?: Currency; + dateFrom?: string; + dateTo?: string; + status?: TransactionStatus | LoanStatus; +} + +// 导入结果 +export interface ImportResult { + success: number; + failed: number; + errors: Array<{ + row: number; + message: string; + }>; +} + +// 导出参数 +export interface ExportParams { + format: 'excel' | 'csv' | 'pdf'; + fields?: string[]; + filters?: SearchParams; +} + +// 标签 +export interface Tag { + id: string; + name: string; + color?: string; + description?: string; + created_at: string; + updated_at?: string; +} + +// 预算 +export interface Budget { + id: string; + categoryId: string; + amount: number; + currency: Currency; + period: 'monthly' | 'yearly'; + year: number; + month?: number; // 1-12, 仅月度预算需要 + created_at: string; + updated_at?: string; +} + +// 预算统计 +export interface BudgetStats { + budget: Budget; + spent: number; + remaining: number; + percentage: number; + transactions: number; +} \ No newline at end of file diff --git a/apps/web-finance/src/utils/data-migration.ts b/apps/web-finance/src/utils/data-migration.ts new file mode 100644 index 00000000..254dc301 --- /dev/null +++ b/apps/web-finance/src/utils/data-migration.ts @@ -0,0 +1,179 @@ +// 数据迁移工具 - 从旧的 localStorage 迁移到 IndexedDB +import type { + Category, + Loan, + Person, + Transaction +} from '#/types/finance'; + +import { importDatabase } from './db'; + +// 旧系统的存储键 +const OLD_STORAGE_KEYS = { + TRANSACTIONS: 'transactions', + CATEGORIES: 'categories', + PERSONS: 'persons', + LOANS: 'loans', +}; + +// 生成新的 ID +function generateNewId(): string { + return Date.now().toString(36) + Math.random().toString(36).substr(2); +} + +// 迁移分类数据 +function migrateCategories(oldCategories: any[]): Category[] { + return oldCategories.map(cat => ({ + id: cat.id || generateNewId(), + name: cat.name, + type: cat.type, + parentId: cat.parentId, + created_at: cat.created_at || new Date().toISOString(), + })); +} + +// 迁移人员数据 +function migratePersons(oldPersons: any[]): Person[] { + return oldPersons.map(person => ({ + id: person.id || generateNewId(), + name: person.name, + roles: person.roles || [], + contact: person.contact, + description: person.description, + created_at: person.created_at || new Date().toISOString(), + })); +} + +// 迁移交易数据 +function migrateTransactions(oldTransactions: any[]): Transaction[] { + return oldTransactions.map(trans => ({ + id: trans.id || generateNewId(), + amount: Number(trans.amount) || 0, + type: trans.type, + categoryId: trans.categoryId, + description: trans.description, + date: trans.date, + quantity: trans.quantity || 1, + project: trans.project, + payer: trans.payer, + payee: trans.payee, + recorder: trans.recorder || '管理员', + currency: trans.currency || 'CNY', + status: trans.status || 'completed', + created_at: trans.created_at || new Date().toISOString(), + })); +} + +// 迁移贷款数据 +function migrateLoans(oldLoans: any[]): Loan[] { + return oldLoans.map(loan => ({ + id: loan.id || generateNewId(), + borrower: loan.borrower, + lender: loan.lender, + amount: Number(loan.amount) || 0, + currency: loan.currency || 'CNY', + startDate: loan.startDate, + dueDate: loan.dueDate, + description: loan.description, + status: loan.status || 'active', + repayments: loan.repayments || [], + created_at: loan.created_at || new Date().toISOString(), + })); +} + +// 从 localStorage 读取旧数据 +function readOldData(key: string): T[] { + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : []; + } catch (error) { + console.error(`Error reading ${key} from localStorage:`, error); + return []; + } +} + +// 执行数据迁移 +export async function migrateData(): Promise<{ + success: boolean; + message: string; + details?: any; +}> { + try { + console.log('开始数据迁移...'); + + // 读取旧数据 + const oldCategories = readOldData(OLD_STORAGE_KEYS.CATEGORIES); + const oldPersons = readOldData(OLD_STORAGE_KEYS.PERSONS); + const oldTransactions = readOldData(OLD_STORAGE_KEYS.TRANSACTIONS); + const oldLoans = readOldData(OLD_STORAGE_KEYS.LOANS); + + console.log('读取到的旧数据:', { + categories: oldCategories.length, + persons: oldPersons.length, + transactions: oldTransactions.length, + loans: oldLoans.length, + }); + + // 如果没有旧数据,则不需要迁移 + if ( + oldCategories.length === 0 && + oldPersons.length === 0 && + oldTransactions.length === 0 && + oldLoans.length === 0 + ) { + return { + success: true, + message: '没有需要迁移的数据', + }; + } + + // 转换数据格式 + const categories = migrateCategories(oldCategories); + const persons = migratePersons(oldPersons); + const transactions = migrateTransactions(oldTransactions); + const loans = migrateLoans(oldLoans); + + // 导入到新系统 + await importDatabase({ + categories, + persons, + transactions, + loans, + }); + + // 迁移成功后,可以选择清除旧数据 + // localStorage.removeItem(OLD_STORAGE_KEYS.CATEGORIES); + // localStorage.removeItem(OLD_STORAGE_KEYS.PERSONS); + // localStorage.removeItem(OLD_STORAGE_KEYS.TRANSACTIONS); + // localStorage.removeItem(OLD_STORAGE_KEYS.LOANS); + + return { + success: true, + message: '数据迁移成功', + details: { + categories: categories.length, + persons: persons.length, + transactions: transactions.length, + loans: loans.length, + }, + }; + } catch (error) { + console.error('数据迁移失败:', error); + return { + success: false, + message: '数据迁移失败', + details: error, + }; + } +} + +// 检查是否需要迁移 +export function needsMigration(): boolean { + const hasOldData = + localStorage.getItem(OLD_STORAGE_KEYS.CATEGORIES) || + localStorage.getItem(OLD_STORAGE_KEYS.PERSONS) || + localStorage.getItem(OLD_STORAGE_KEYS.TRANSACTIONS) || + localStorage.getItem(OLD_STORAGE_KEYS.LOANS); + + return !!hasOldData; +} \ No newline at end of file diff --git a/apps/web-finance/src/utils/db.ts b/apps/web-finance/src/utils/db.ts new file mode 100644 index 00000000..0f3b0aa3 --- /dev/null +++ b/apps/web-finance/src/utils/db.ts @@ -0,0 +1,324 @@ +// IndexedDB 工具类 +import type { + Category, + Loan, + Person, + Transaction +} from '#/types/finance'; + +const DB_NAME = 'TokenRecordsDB'; +const DB_VERSION = 2; // 升级版本号以添加新表 + +// 数据表名称 +export const STORES = { + TRANSACTIONS: 'transactions', + CATEGORIES: 'categories', + PERSONS: 'persons', + LOANS: 'loans', + TAGS: 'tags', + BUDGETS: 'budgets', +} as const; + +// IndexedDB 实例 +let db: IDBDatabase | null = null; + +// 初始化数据库 +export function initDB(): Promise { + return new Promise((resolve, reject) => { + if (db) { + resolve(db); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + reject(new Error('Failed to open database')); + }; + + request.onsuccess = () => { + db = request.result; + resolve(db); + }; + + request.onupgradeneeded = (event) => { + const database = (event.target as IDBOpenDBRequest).result; + + // 创建交易表 + if (!database.objectStoreNames.contains(STORES.TRANSACTIONS)) { + const transactionStore = database.createObjectStore(STORES.TRANSACTIONS, { + keyPath: 'id', + }); + transactionStore.createIndex('type', 'type', { unique: false }); + transactionStore.createIndex('categoryId', 'categoryId', { unique: false }); + transactionStore.createIndex('date', 'date', { unique: false }); + transactionStore.createIndex('currency', 'currency', { unique: false }); + transactionStore.createIndex('status', 'status', { unique: false }); + } + + // 创建分类表 + if (!database.objectStoreNames.contains(STORES.CATEGORIES)) { + const categoryStore = database.createObjectStore(STORES.CATEGORIES, { + keyPath: 'id', + }); + categoryStore.createIndex('type', 'type', { unique: false }); + categoryStore.createIndex('parentId', 'parentId', { unique: false }); + } + + // 创建人员表 + if (!database.objectStoreNames.contains(STORES.PERSONS)) { + const personStore = database.createObjectStore(STORES.PERSONS, { + keyPath: 'id', + }); + personStore.createIndex('name', 'name', { unique: false }); + } + + // 创建贷款表 + if (!database.objectStoreNames.contains(STORES.LOANS)) { + const loanStore = database.createObjectStore(STORES.LOANS, { + keyPath: 'id', + }); + loanStore.createIndex('status', 'status', { unique: false }); + loanStore.createIndex('borrower', 'borrower', { unique: false }); + loanStore.createIndex('lender', 'lender', { unique: false }); + } + + // 创建标签表 + if (!database.objectStoreNames.contains(STORES.TAGS)) { + const tagStore = database.createObjectStore(STORES.TAGS, { + keyPath: 'id', + }); + tagStore.createIndex('name', 'name', { unique: false }); + } + + // 创建预算表 + if (!database.objectStoreNames.contains(STORES.BUDGETS)) { + const budgetStore = database.createObjectStore(STORES.BUDGETS, { + keyPath: 'id', + }); + budgetStore.createIndex('categoryId', 'categoryId', { unique: false }); + budgetStore.createIndex('year', 'year', { unique: false }); + budgetStore.createIndex('period', 'period', { unique: false }); + } + }; + }); +} + +// 获取数据库实例 +export async function getDB(): Promise { + if (!db) { + db = await initDB(); + } + return db; +} + +// 通用的添加数据方法 +export async function add(storeName: string, data: T): Promise { + const database = await getDB(); + return new Promise((resolve, reject) => { + const transaction = database.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + + // 确保数据可以被IndexedDB存储(深拷贝并序列化) + const serializedData = JSON.parse(JSON.stringify(data)); + const request = store.add(serializedData); + + request.onsuccess = () => { + resolve(data); + }; + + request.onerror = () => { + console.error('IndexedDB add error:', request.error); + reject(new Error(`Failed to add data to ${storeName}: ${request.error?.message}`)); + }; + }); +} + +// 通用的更新数据方法 +export async function update(storeName: string, data: T): Promise { + const database = await getDB(); + return new Promise((resolve, reject) => { + const transaction = database.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + + // 确保数据可以被IndexedDB存储(深拷贝并序列化) + const serializedData = JSON.parse(JSON.stringify(data)); + const request = store.put(serializedData); + + request.onsuccess = () => { + resolve(data); + }; + + request.onerror = () => { + console.error('IndexedDB update error:', request.error); + reject(new Error(`Failed to update data in ${storeName}: ${request.error?.message}`)); + }; + }); +} + +// 通用的删除数据方法 +export async function remove(storeName: string, id: string): Promise { + const database = await getDB(); + return new Promise((resolve, reject) => { + const transaction = database.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.delete(id); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Failed to delete data from ${storeName}`)); + }; + }); +} + +// 通用的获取单条数据方法 +export async function get(storeName: string, id: string): Promise { + const database = await getDB(); + return new Promise((resolve, reject) => { + const transaction = database.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.get(id); + + request.onsuccess = () => { + resolve(request.result || null); + }; + + request.onerror = () => { + reject(new Error(`Failed to get data from ${storeName}`)); + }; + }); +} + +// 通用的获取所有数据方法 +export async function getAll(storeName: string): Promise { + const database = await getDB(); + return new Promise((resolve, reject) => { + const transaction = database.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const request = store.getAll(); + + request.onsuccess = () => { + resolve(request.result || []); + }; + + request.onerror = () => { + reject(new Error(`Failed to get all data from ${storeName}`)); + }; + }); +} + +// 按索引查询 +export async function getByIndex( + storeName: string, + indexName: string, + value: any, +): Promise { + const database = await getDB(); + return new Promise((resolve, reject) => { + const transaction = database.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const index = store.index(indexName); + const request = index.getAll(value); + + request.onsuccess = () => { + resolve(request.result || []); + }; + + request.onerror = () => { + reject(new Error(`Failed to get data by index from ${storeName}`)); + }; + }); +} + +// 清空数据表 +export async function clear(storeName: string): Promise { + const database = await getDB(); + return new Promise((resolve, reject) => { + const transaction = database.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.clear(); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Failed to clear ${storeName}`)); + }; + }); +} + +// 批量添加数据 +export async function addBatch(storeName: string, dataList: T[]): Promise { + const database = await getDB(); + return new Promise((resolve, reject) => { + const transaction = database.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + + dataList.forEach((data) => { + // 确保数据可以被IndexedDB存储(深拷贝并序列化) + const serializedData = JSON.parse(JSON.stringify(data)); + store.add(serializedData); + }); + + transaction.oncomplete = () => { + resolve(); + }; + + transaction.onerror = () => { + console.error('IndexedDB addBatch error:', transaction.error); + reject(new Error(`Failed to add batch data to ${storeName}: ${transaction.error?.message}`)); + }; + }); +} + +// 导出数据库 +export async function exportDatabase(): Promise<{ + transactions: Transaction[]; + categories: Category[]; + persons: Person[]; + loans: Loan[]; +}> { + const transactions = await getAll(STORES.TRANSACTIONS); + const categories = await getAll(STORES.CATEGORIES); + const persons = await getAll(STORES.PERSONS); + const loans = await getAll(STORES.LOANS); + + return { + transactions, + categories, + persons, + loans, + }; +} + +// 导入数据库 +export async function importDatabase(data: { + transactions?: Transaction[]; + categories?: Category[]; + persons?: Person[]; + loans?: Loan[]; +}): Promise { + if (data.categories) { + await clear(STORES.CATEGORIES); + await addBatch(STORES.CATEGORIES, data.categories); + } + + if (data.persons) { + await clear(STORES.PERSONS); + await addBatch(STORES.PERSONS, data.persons); + } + + if (data.transactions) { + await clear(STORES.TRANSACTIONS); + await addBatch(STORES.TRANSACTIONS, data.transactions); + } + + if (data.loans) { + await clear(STORES.LOANS); + await addBatch(STORES.LOANS, data.loans); + } +} \ No newline at end of file diff --git a/apps/web-finance/src/utils/export.ts b/apps/web-finance/src/utils/export.ts new file mode 100644 index 00000000..000344ff --- /dev/null +++ b/apps/web-finance/src/utils/export.ts @@ -0,0 +1,199 @@ +import type { Transaction, Category, Person } from '#/types/finance'; + +import dayjs from 'dayjs'; + +/** + * 导出数据为CSV格式 + */ +export function exportToCSV(data: any[], filename: string) { + if (data.length === 0) { + return; + } + + // 获取所有列名 + const headers = Object.keys(data[0]); + + // 创建CSV内容 + let csvContent = '\uFEFF'; // UTF-8 BOM + + // 添加表头 + csvContent += headers.join(',') + '\n'; + + // 添加数据行 + data.forEach(row => { + const values = headers.map(header => { + const value = row[header]; + // 处理包含逗号或换行符的值 + if (typeof value === 'string' && (value.includes(',') || value.includes('\n'))) { + return `"${value.replace(/"/g, '""')}"`; + } + return value ?? ''; + }); + csvContent += values.join(',') + '\n'; + }); + + // 创建Blob并下载 + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', `${filename}_${dayjs().format('YYYYMMDD_HHmmss')}.csv`); + link.style.visibility = 'hidden'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +/** + * 导出交易数据 + */ +export function exportTransactions( + transactions: Transaction[], + categories: Category[], + persons: Person[] +) { + // 创建分类和人员的映射 + const categoryMap = new Map(categories.map(c => [c.id, c.name])); + const personMap = new Map(persons.map(p => [p.id, p.name])); + + // 转换交易数据为导出格式 + const exportData = transactions.map(t => ({ + 日期: t.date, + 类型: t.type === 'income' ? '收入' : '支出', + 分类: categoryMap.get(t.categoryId) || '', + 金额: t.amount, + 货币: t.currency, + 项目: t.project || '', + 付款人: t.payer || '', + 收款人: t.payee || '', + 数量: t.quantity, + 单价: t.quantity > 1 ? (t.amount / t.quantity).toFixed(2) : t.amount, + 状态: t.status === 'completed' ? '已完成' : t.status === 'pending' ? '待处理' : '已取消', + 描述: t.description || '', + 记录人: t.recorder || '', + 创建时间: t.created_at, + 更新时间: t.updated_at + })); + + exportToCSV(exportData, '交易记录'); +} + +/** + * 导出数据为JSON格式 + */ +export function exportToJSON(data: any, filename: string) { + const jsonContent = JSON.stringify(data, null, 2); + + const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', `${filename}_${dayjs().format('YYYYMMDD_HHmmss')}.json`); + link.style.visibility = 'hidden'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +/** + * 生成导入模板 + */ +export function generateImportTemplate() { + const template = [ + { + date: '2025-08-05', + type: 'expense', + category: '餐饮', + amount: 100.00, + currency: 'CNY', + description: '午餐', + project: '项目名称', + payer: '付款人', + payee: '收款人', + status: 'completed', + tags: '标签1,标签2', + }, + { + date: '2025-08-05', + type: 'income', + category: '工资', + amount: 5000.00, + currency: 'CNY', + description: '月薪', + project: '', + payer: '公司', + payee: '自己', + status: 'completed', + tags: '', + }, + ]; + + exportToCSV(template, 'transaction_import_template'); +} + +/** + * 导出所有数据(完整备份) + */ +export function exportAllData( + transactions: Transaction[], + categories: Category[], + persons: Person[] +) { + const exportData = { + version: '1.0', + exportDate: dayjs().format('YYYY-MM-DD HH:mm:ss'), + data: { + transactions, + categories, + persons + } + }; + + exportToJSON(exportData, '财务数据备份'); +} + +/** + * 解析CSV文件 + */ +export function parseCSV(text: string): Record[] { + const lines = text.split('\n').filter(line => line.trim()); + if (lines.length === 0) return []; + + // 解析表头 + const headers = lines[0].split(',').map(h => h.trim()); + + // 解析数据行 + const data = []; + for (let i = 1; i < lines.length; i++) { + const values = []; + let current = ''; + let inQuotes = false; + + for (let j = 0; j < lines[i].length; j++) { + const char = lines[i][j]; + + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === ',' && !inQuotes) { + values.push(current.trim()); + current = ''; + } else { + current += char; + } + } + values.push(current.trim()); + + // 创建对象 + const row: Record = {}; + headers.forEach((header, index) => { + row[header] = values[index] || ''; + }); + data.push(row); + } + + return data; +} \ No newline at end of file diff --git a/apps/web-finance/src/utils/import.ts b/apps/web-finance/src/utils/import.ts new file mode 100644 index 00000000..034737e1 --- /dev/null +++ b/apps/web-finance/src/utils/import.ts @@ -0,0 +1,266 @@ +import type { Transaction, Category, Person } from '#/types/finance'; + +import dayjs from 'dayjs'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * 解析CSV文本 + */ +export function parseCSV(text: string): Record[] { + const lines = text.split('\n').filter(line => line.trim()); + if (lines.length < 2) return []; + + // 解析表头 + const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, '')); + + // 解析数据行 + const data: Record[] = []; + for (let i = 1; i < lines.length; i++) { + const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, '')); + if (values.length === headers.length) { + const row: Record = {}; + headers.forEach((header, index) => { + row[header] = values[index]; + }); + data.push(row); + } + } + + return data; +} + +/** + * 导入交易数据从CSV + */ +export function importTransactionsFromCSV( + csvData: Record[], + categories: Category[], + persons: Person[] +): { + transactions: Partial[], + errors: string[], + newCategories: string[], + newPersons: string[] +} { + const transactions: Partial[] = []; + const errors: string[] = []; + const newCategories = new Set(); + const newPersons = new Set(); + + // 创建分类和人员的反向映射(名称到ID) + const categoryMap = new Map(categories.map(c => [c.name, c])); + + csvData.forEach((row, index) => { + try { + // 解析类型 + const type = row['类型'] === '收入' ? 'income' : 'expense'; + + // 查找或标记新分类 + let categoryId = ''; + const categoryName = row['分类']; + if (categoryName) { + const category = categoryMap.get(categoryName); + if (category && category.type === type) { + categoryId = category.id; + } else { + newCategories.add(categoryName); + } + } + + // 标记新的人员 + if (row['付款人'] && !persons.some(p => p.name === row['付款人'])) { + newPersons.add(row['付款人']); + } + if (row['收款人'] && !persons.some(p => p.name === row['收款人'])) { + newPersons.add(row['收款人']); + } + + // 解析金额 + const amount = parseFloat(row['金额']); + if (isNaN(amount)) { + errors.push(`第${index + 2}行: 金额格式错误`); + return; + } + + // 解析日期 + const date = row['日期'] ? dayjs(row['日期']).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'); + if (!dayjs(date).isValid()) { + errors.push(`第${index + 2}行: 日期格式错误`); + return; + } + + // 解析状态 + let status: 'pending' | 'completed' | 'cancelled' = 'completed'; + if (row['状态'] === '待处理') status = 'pending'; + else if (row['状态'] === '已取消') status = 'cancelled'; + + // 创建交易对象 + const transaction: Partial = { + id: uuidv4(), + type, + categoryId, + amount, + currency: row['货币'] || 'CNY', + date, + project: row['项目'] || '', + payer: row['付款人'] || '', + payee: row['收款人'] || '', + quantity: parseInt(row['数量']) || 1, + status, + description: row['描述'] || '', + recorder: row['记录人'] || '导入', + created_at: dayjs().format('YYYY-MM-DD HH:mm:ss'), + updated_at: dayjs().format('YYYY-MM-DD HH:mm:ss') + }; + + transactions.push(transaction); + } catch (error) { + errors.push(`第${index + 2}行: 数据解析错误`); + } + }); + + return { + transactions, + errors, + newCategories: Array.from(newCategories), + newPersons: Array.from(newPersons) + }; +} + +/** + * 导入JSON备份数据 + */ +export function importFromJSON(jsonData: any): { + valid: boolean, + data?: { + transactions: Transaction[], + categories: Category[], + persons: Person[] + }, + error?: string +} { + try { + // 验证数据格式 + if (!jsonData.version || !jsonData.data) { + return { valid: false, error: '无效的备份文件格式' }; + } + + const { transactions, categories, persons } = jsonData.data; + + // 验证必要字段 + if (!Array.isArray(transactions) || !Array.isArray(categories) || !Array.isArray(persons)) { + return { valid: false, error: '备份数据不完整' }; + } + + // 为导入的数据生成新的ID(避免冲突) + const idMap = new Map(); + + // 处理分类 + const newCategories = categories.map(c => { + const newId = uuidv4(); + idMap.set(c.id, newId); + return { ...c, id: newId }; + }); + + // 处理人员 + const newPersons = persons.map(p => { + const newId = uuidv4(); + idMap.set(p.id, newId); + return { ...p, id: newId }; + }); + + // 处理交易(更新关联的ID) + const newTransactions = transactions.map(t => { + const newId = uuidv4(); + return { + ...t, + id: newId, + categoryId: idMap.get(t.categoryId) || t.categoryId, + created_at: t.created_at || dayjs().format('YYYY-MM-DD HH:mm:ss'), + updated_at: dayjs().format('YYYY-MM-DD HH:mm:ss') + }; + }); + + return { + valid: true, + data: { + transactions: newTransactions, + categories: newCategories, + persons: newPersons + } + }; + } catch (error) { + return { valid: false, error: '解析备份文件失败' }; + } +} + +/** + * 读取文件内容 + */ +export function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target?.result as string); + reader.onerror = reject; + reader.readAsText(file); + }); +} + +/** + * 生成导入模板 + */ +export function generateImportTemplate(): string { + const headers = [ + '日期', + '类型', + '分类', + '金额', + '货币', + '项目', + '付款人', + '收款人', + '数量', + '状态', + '描述', + '记录人' + ]; + + const examples = [ + [ + dayjs().format('YYYY-MM-DD'), + '支出', + '餐饮', + '50.00', + 'CNY', + '项目A', + '张三', + '餐厅', + '1', + '已完成', + '午餐', + '管理员' + ], + [ + dayjs().subtract(1, 'day').format('YYYY-MM-DD'), + '收入', + '工资', + '10000.00', + 'CNY', + '', + '公司', + '李四', + '1', + '已完成', + '月薪', + '管理员' + ] + ]; + + let csvContent = '\uFEFF'; // UTF-8 BOM + csvContent += headers.join(',') + '\n'; + examples.forEach(row => { + csvContent += row.join(',') + '\n'; + }); + + return csvContent; +} \ No newline at end of file diff --git a/apps/web-finance/src/views/_core/README.md b/apps/web-finance/src/views/_core/README.md new file mode 100644 index 00000000..8248afe6 --- /dev/null +++ b/apps/web-finance/src/views/_core/README.md @@ -0,0 +1,3 @@ +# \_core + +此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。 diff --git a/apps/web-finance/src/views/_core/about/index.vue b/apps/web-finance/src/views/_core/about/index.vue new file mode 100644 index 00000000..0ee52433 --- /dev/null +++ b/apps/web-finance/src/views/_core/about/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/apps/web-finance/src/views/_core/authentication/code-login.vue b/apps/web-finance/src/views/_core/authentication/code-login.vue new file mode 100644 index 00000000..acfd1fd7 --- /dev/null +++ b/apps/web-finance/src/views/_core/authentication/code-login.vue @@ -0,0 +1,69 @@ + + + diff --git a/apps/web-finance/src/views/_core/authentication/forget-password.vue b/apps/web-finance/src/views/_core/authentication/forget-password.vue new file mode 100644 index 00000000..fef0d427 --- /dev/null +++ b/apps/web-finance/src/views/_core/authentication/forget-password.vue @@ -0,0 +1,43 @@ + + + diff --git a/apps/web-finance/src/views/_core/authentication/login.vue b/apps/web-finance/src/views/_core/authentication/login.vue new file mode 100644 index 00000000..f720f010 --- /dev/null +++ b/apps/web-finance/src/views/_core/authentication/login.vue @@ -0,0 +1,118 @@ + + + diff --git a/apps/web-finance/src/views/_core/authentication/qrcode-login.vue b/apps/web-finance/src/views/_core/authentication/qrcode-login.vue new file mode 100644 index 00000000..23f5f2da --- /dev/null +++ b/apps/web-finance/src/views/_core/authentication/qrcode-login.vue @@ -0,0 +1,10 @@ + + + diff --git a/apps/web-finance/src/views/_core/authentication/register.vue b/apps/web-finance/src/views/_core/authentication/register.vue new file mode 100644 index 00000000..b1a5de72 --- /dev/null +++ b/apps/web-finance/src/views/_core/authentication/register.vue @@ -0,0 +1,96 @@ + + + diff --git a/apps/web-finance/src/views/_core/fallback/coming-soon.vue b/apps/web-finance/src/views/_core/fallback/coming-soon.vue new file mode 100644 index 00000000..f394930f --- /dev/null +++ b/apps/web-finance/src/views/_core/fallback/coming-soon.vue @@ -0,0 +1,7 @@ + + + diff --git a/apps/web-finance/src/views/_core/fallback/forbidden.vue b/apps/web-finance/src/views/_core/fallback/forbidden.vue new file mode 100644 index 00000000..8ea65fed --- /dev/null +++ b/apps/web-finance/src/views/_core/fallback/forbidden.vue @@ -0,0 +1,9 @@ + + + diff --git a/apps/web-finance/src/views/_core/fallback/internal-error.vue b/apps/web-finance/src/views/_core/fallback/internal-error.vue new file mode 100644 index 00000000..819a47d5 --- /dev/null +++ b/apps/web-finance/src/views/_core/fallback/internal-error.vue @@ -0,0 +1,9 @@ + + + diff --git a/apps/web-finance/src/views/_core/fallback/not-found.vue b/apps/web-finance/src/views/_core/fallback/not-found.vue new file mode 100644 index 00000000..4d178e9c --- /dev/null +++ b/apps/web-finance/src/views/_core/fallback/not-found.vue @@ -0,0 +1,9 @@ + + + diff --git a/apps/web-finance/src/views/_core/fallback/offline.vue b/apps/web-finance/src/views/_core/fallback/offline.vue new file mode 100644 index 00000000..5de4a88d --- /dev/null +++ b/apps/web-finance/src/views/_core/fallback/offline.vue @@ -0,0 +1,9 @@ + + + diff --git a/apps/web-finance/src/views/analytics/components/CategoryPieChart.vue b/apps/web-finance/src/views/analytics/components/CategoryPieChart.vue new file mode 100644 index 00000000..add21536 --- /dev/null +++ b/apps/web-finance/src/views/analytics/components/CategoryPieChart.vue @@ -0,0 +1,122 @@ + + + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/analytics/components/MonthlyComparisonChart.vue b/apps/web-finance/src/views/analytics/components/MonthlyComparisonChart.vue new file mode 100644 index 00000000..c6726fec --- /dev/null +++ b/apps/web-finance/src/views/analytics/components/MonthlyComparisonChart.vue @@ -0,0 +1,170 @@ + + + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/analytics/components/PersonAnalysisChart.vue b/apps/web-finance/src/views/analytics/components/PersonAnalysisChart.vue new file mode 100644 index 00000000..55cb8463 --- /dev/null +++ b/apps/web-finance/src/views/analytics/components/PersonAnalysisChart.vue @@ -0,0 +1,162 @@ + + + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/analytics/components/TrendChart.vue b/apps/web-finance/src/views/analytics/components/TrendChart.vue new file mode 100644 index 00000000..deb3c2ad --- /dev/null +++ b/apps/web-finance/src/views/analytics/components/TrendChart.vue @@ -0,0 +1,164 @@ + + + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/analytics/overview/index.vue b/apps/web-finance/src/views/analytics/overview/index.vue new file mode 100644 index 00000000..4c2581c8 --- /dev/null +++ b/apps/web-finance/src/views/analytics/overview/index.vue @@ -0,0 +1,166 @@ + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/analytics/reports/custom.vue b/apps/web-finance/src/views/analytics/reports/custom.vue new file mode 100644 index 00000000..22fa1c82 --- /dev/null +++ b/apps/web-finance/src/views/analytics/reports/custom.vue @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/analytics/reports/daily.vue b/apps/web-finance/src/views/analytics/reports/daily.vue new file mode 100644 index 00000000..2e64b1ef --- /dev/null +++ b/apps/web-finance/src/views/analytics/reports/daily.vue @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/analytics/reports/monthly.vue b/apps/web-finance/src/views/analytics/reports/monthly.vue new file mode 100644 index 00000000..bae9da90 --- /dev/null +++ b/apps/web-finance/src/views/analytics/reports/monthly.vue @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/analytics/reports/yearly.vue b/apps/web-finance/src/views/analytics/reports/yearly.vue new file mode 100644 index 00000000..d1a9c41e --- /dev/null +++ b/apps/web-finance/src/views/analytics/reports/yearly.vue @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/analytics/trends/index.vue b/apps/web-finance/src/views/analytics/trends/index.vue new file mode 100644 index 00000000..77db35f4 --- /dev/null +++ b/apps/web-finance/src/views/analytics/trends/index.vue @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/dashboard/analytics/analytics-trends.vue b/apps/web-finance/src/views/dashboard/analytics/analytics-trends.vue new file mode 100644 index 00000000..f1f0b232 --- /dev/null +++ b/apps/web-finance/src/views/dashboard/analytics/analytics-trends.vue @@ -0,0 +1,98 @@ + + + diff --git a/apps/web-finance/src/views/dashboard/analytics/analytics-visits-data.vue b/apps/web-finance/src/views/dashboard/analytics/analytics-visits-data.vue new file mode 100644 index 00000000..190fb41f --- /dev/null +++ b/apps/web-finance/src/views/dashboard/analytics/analytics-visits-data.vue @@ -0,0 +1,82 @@ + + + diff --git a/apps/web-finance/src/views/dashboard/analytics/analytics-visits-sales.vue b/apps/web-finance/src/views/dashboard/analytics/analytics-visits-sales.vue new file mode 100644 index 00000000..02f50912 --- /dev/null +++ b/apps/web-finance/src/views/dashboard/analytics/analytics-visits-sales.vue @@ -0,0 +1,46 @@ + + + diff --git a/apps/web-finance/src/views/dashboard/analytics/analytics-visits-source.vue b/apps/web-finance/src/views/dashboard/analytics/analytics-visits-source.vue new file mode 100644 index 00000000..0915c7af --- /dev/null +++ b/apps/web-finance/src/views/dashboard/analytics/analytics-visits-source.vue @@ -0,0 +1,65 @@ + + + diff --git a/apps/web-finance/src/views/dashboard/analytics/analytics-visits.vue b/apps/web-finance/src/views/dashboard/analytics/analytics-visits.vue new file mode 100644 index 00000000..7e0f1013 --- /dev/null +++ b/apps/web-finance/src/views/dashboard/analytics/analytics-visits.vue @@ -0,0 +1,55 @@ + + + diff --git a/apps/web-finance/src/views/dashboard/analytics/index.vue b/apps/web-finance/src/views/dashboard/analytics/index.vue new file mode 100644 index 00000000..5e3d6d28 --- /dev/null +++ b/apps/web-finance/src/views/dashboard/analytics/index.vue @@ -0,0 +1,90 @@ + + + diff --git a/apps/web-finance/src/views/dashboard/workspace/index.vue b/apps/web-finance/src/views/dashboard/workspace/index.vue new file mode 100644 index 00000000..4a63d37a --- /dev/null +++ b/apps/web-finance/src/views/dashboard/workspace/index.vue @@ -0,0 +1,264 @@ + + + diff --git a/apps/web-finance/src/views/demos/antd/index.vue b/apps/web-finance/src/views/demos/antd/index.vue new file mode 100644 index 00000000..b3b05cc1 --- /dev/null +++ b/apps/web-finance/src/views/demos/antd/index.vue @@ -0,0 +1,66 @@ + + + diff --git a/apps/web-finance/src/views/finance/budget/components/budget-setting.vue b/apps/web-finance/src/views/finance/budget/components/budget-setting.vue new file mode 100644 index 00000000..0073e8ee --- /dev/null +++ b/apps/web-finance/src/views/finance/budget/components/budget-setting.vue @@ -0,0 +1,254 @@ + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/finance/budget/index.vue b/apps/web-finance/src/views/finance/budget/index.vue new file mode 100644 index 00000000..f6cd3bdd --- /dev/null +++ b/apps/web-finance/src/views/finance/budget/index.vue @@ -0,0 +1,357 @@ + + + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/finance/category/components/category-form.vue b/apps/web-finance/src/views/finance/category/components/category-form.vue new file mode 100644 index 00000000..b85df270 --- /dev/null +++ b/apps/web-finance/src/views/finance/category/components/category-form.vue @@ -0,0 +1,127 @@ + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/finance/category/index.vue b/apps/web-finance/src/views/finance/category/index.vue new file mode 100644 index 00000000..40934f2c --- /dev/null +++ b/apps/web-finance/src/views/finance/category/index.vue @@ -0,0 +1,192 @@ + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/finance/dashboard/index.vue b/apps/web-finance/src/views/finance/dashboard/index.vue new file mode 100644 index 00000000..e17f8c4b --- /dev/null +++ b/apps/web-finance/src/views/finance/dashboard/index.vue @@ -0,0 +1,147 @@ + + + \ No newline at end of file diff --git a/apps/web-finance/src/views/finance/loan/components/loan-form.vue b/apps/web-finance/src/views/finance/loan/components/loan-form.vue new file mode 100644 index 00000000..fd385a86 --- /dev/null +++ b/apps/web-finance/src/views/finance/loan/components/loan-form.vue @@ -0,0 +1,210 @@ + + +