feat: Add TokenRecords finance management system
- Created new finance application based on Vue Vben Admin - Implemented transaction management, category management, and loan tracking - Added person management for tracking financial relationships - Integrated budget management and financial analytics - Added data import/export functionality - Implemented responsive design for mobile support - Added comprehensive testing with Playwright 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
8
apps/web-finance/.env
Normal file
@@ -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
|
||||
7
apps/web-finance/.env.analyze
Normal file
@@ -0,0 +1,7 @@
|
||||
# public path
|
||||
VITE_BASE=/
|
||||
|
||||
# Basic interface address SPA
|
||||
VITE_GLOB_API_URL=/api
|
||||
|
||||
VITE_VISUALIZER=true
|
||||
16
apps/web-finance/.env.development
Normal file
@@ -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
|
||||
19
apps/web-finance/.env.production
Normal file
@@ -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
|
||||
105
apps/web-finance/README.md
Normal file
@@ -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
|
||||
BIN
apps/web-finance/analytics-error-simple.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/web-finance/analytics-error.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/web-finance/analytics-page-state.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
65
apps/web-finance/check-server.js
Normal file
@@ -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();
|
||||
}
|
||||
})();
|
||||
BIN
apps/web-finance/finance-system-error.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
apps/web-finance/finance-system-test.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
35
apps/web-finance/index.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta name="renderer" content="webkit" />
|
||||
<meta name="description" content="A Modern Back-end Management System" />
|
||||
<meta name="keywords" content="Vben Admin Vue3 Vite" />
|
||||
<meta name="author" content="Vben" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
|
||||
/>
|
||||
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
|
||||
<title><%= VITE_APP_TITLE %></title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<script>
|
||||
// 生产环境下注入百度统计
|
||||
if (window._VBEN_ADMIN_PRO_APP_CONF_) {
|
||||
var _hmt = _hmt || [];
|
||||
(function () {
|
||||
var hm = document.createElement('script');
|
||||
hm.src =
|
||||
'https://hm.baidu.com/hm.js?b38e689f40558f20a9a686d7f6f33edf';
|
||||
var s = document.getElementsByTagName('script')[0];
|
||||
s.parentNode.insertBefore(hm, s);
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
apps/web-finance/login-page.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
73
apps/web-finance/manual-check.js
Normal file
@@ -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(() => {});
|
||||
})();
|
||||
55
apps/web-finance/package.json
Normal file
@@ -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:"
|
||||
}
|
||||
}
|
||||
1
apps/web-finance/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
BIN
apps/web-finance/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
52
apps/web-finance/quick-test.js
Normal file
@@ -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();
|
||||
})();
|
||||
211
apps/web-finance/src/adapter/component/index.ts
Normal file
@@ -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 = <T extends Component>(
|
||||
component: T,
|
||||
type: 'input' | 'select',
|
||||
componentProps: Recordable<any> = {},
|
||||
) => {
|
||||
return defineComponent({
|
||||
name: component.name,
|
||||
inheritAttrs: false,
|
||||
setup: (props: any, { attrs, expose, slots }) => {
|
||||
const placeholder =
|
||||
props?.placeholder ||
|
||||
attrs?.placeholder ||
|
||||
$t(`ui.placeholder.${type}`);
|
||||
// 透传组件暴露的方法
|
||||
const innerRef = ref();
|
||||
expose(
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, key) => innerRef.value?.[key],
|
||||
has: (_target, key) => key in (innerRef.value || {}),
|
||||
},
|
||||
),
|
||||
);
|
||||
return () =>
|
||||
h(
|
||||
component,
|
||||
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
|
||||
slots,
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
||||
export type ComponentType =
|
||||
| 'ApiSelect'
|
||||
| 'ApiTreeSelect'
|
||||
| 'AutoComplete'
|
||||
| 'Checkbox'
|
||||
| 'CheckboxGroup'
|
||||
| 'DatePicker'
|
||||
| 'DefaultButton'
|
||||
| 'Divider'
|
||||
| 'IconPicker'
|
||||
| 'Input'
|
||||
| 'InputNumber'
|
||||
| 'InputPassword'
|
||||
| 'Mentions'
|
||||
| 'PrimaryButton'
|
||||
| 'Radio'
|
||||
| 'RadioGroup'
|
||||
| 'RangePicker'
|
||||
| 'Rate'
|
||||
| 'Select'
|
||||
| 'Space'
|
||||
| 'Switch'
|
||||
| 'Textarea'
|
||||
| 'TimePicker'
|
||||
| 'TreeSelect'
|
||||
| 'Upload'
|
||||
| BaseFormComponentType;
|
||||
|
||||
async function initComponentAdapter() {
|
||||
const components: Partial<Record<ComponentType, Component>> = {
|
||||
// 如果你的组件体积比较大,可以使用异步加载
|
||||
// Button: () =>
|
||||
// import('xxx').then((res) => res.Button),
|
||||
ApiSelect: withDefaultPlaceholder(
|
||||
{
|
||||
...ApiComponent,
|
||||
name: 'ApiSelect',
|
||||
},
|
||||
'select',
|
||||
{
|
||||
component: Select,
|
||||
loadingSlot: 'suffixIcon',
|
||||
visibleEvent: 'onDropdownVisibleChange',
|
||||
modelPropName: 'value',
|
||||
},
|
||||
),
|
||||
ApiTreeSelect: withDefaultPlaceholder(
|
||||
{
|
||||
...ApiComponent,
|
||||
name: 'ApiTreeSelect',
|
||||
},
|
||||
'select',
|
||||
{
|
||||
component: TreeSelect,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
optionsPropName: 'treeData',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
},
|
||||
),
|
||||
AutoComplete,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
DatePicker,
|
||||
// 自定义默认按钮
|
||||
DefaultButton: (props, { attrs, slots }) => {
|
||||
return h(Button, { ...props, attrs, type: 'default' }, slots);
|
||||
},
|
||||
Divider,
|
||||
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
|
||||
iconSlot: 'addonAfter',
|
||||
inputComponent: Input,
|
||||
modelValueProp: 'value',
|
||||
}),
|
||||
Input: withDefaultPlaceholder(Input, 'input'),
|
||||
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
|
||||
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
||||
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
||||
// 自定义主要按钮
|
||||
PrimaryButton: (props, { attrs, slots }) => {
|
||||
return h(Button, { ...props, attrs, type: 'primary' }, slots);
|
||||
},
|
||||
Radio,
|
||||
RadioGroup,
|
||||
RangePicker,
|
||||
Rate,
|
||||
Select: withDefaultPlaceholder(Select, 'select'),
|
||||
Space,
|
||||
Switch,
|
||||
Textarea: withDefaultPlaceholder(Textarea, 'input'),
|
||||
TimePicker,
|
||||
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
||||
Upload,
|
||||
};
|
||||
|
||||
// 将组件注册到全局共享状态中
|
||||
globalShareState.setComponents(components);
|
||||
|
||||
// 定义全局共享状态中的消息提示
|
||||
globalShareState.defineMessage({
|
||||
// 复制成功消息提示
|
||||
copyPreferencesSuccess: (title, content) => {
|
||||
notification.success({
|
||||
description: content,
|
||||
message: title,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { initComponentAdapter };
|
||||
49
apps/web-finance/src/adapter/form.ts
Normal file
@@ -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<ComponentType>({
|
||||
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<ComponentType>;
|
||||
|
||||
export { initSetupVbenForm, useVbenForm, z };
|
||||
|
||||
export type VbenFormSchema = FormSchema<ComponentType>;
|
||||
export type { VbenFormProps };
|
||||
69
apps/web-finance/src/adapter/vxe-table.ts
Normal file
@@ -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';
|
||||
51
apps/web-finance/src/api/core/auth.ts
Normal file
@@ -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<AuthApi.LoginResult>('/auth/login', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新accessToken
|
||||
*/
|
||||
export async function refreshTokenApi() {
|
||||
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
export async function logoutApi() {
|
||||
return baseRequestClient.post('/auth/logout', {
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户权限码
|
||||
*/
|
||||
export async function getAccessCodesApi() {
|
||||
return requestClient.get<string[]>('/auth/codes');
|
||||
}
|
||||
3
apps/web-finance/src/api/core/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './auth';
|
||||
export * from './menu';
|
||||
export * from './user';
|
||||
10
apps/web-finance/src/api/core/menu.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordStringComponent } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 获取用户所有菜单
|
||||
*/
|
||||
export async function getAllMenusApi() {
|
||||
return requestClient.get<RouteRecordStringComponent[]>('/menu/all');
|
||||
}
|
||||
10
apps/web-finance/src/api/core/user.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { UserInfo } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
export async function getUserInfoApi() {
|
||||
return requestClient.get<UserInfo>('/user/info');
|
||||
}
|
||||
37
apps/web-finance/src/api/finance/category.ts
Normal file
@@ -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<Category>) {
|
||||
return categoryService.create(data);
|
||||
}
|
||||
|
||||
// 更新分类
|
||||
export async function updateCategory(id: string, data: Partial<Category>) {
|
||||
return categoryService.update(id, data);
|
||||
}
|
||||
|
||||
// 删除分类
|
||||
export async function deleteCategory(id: string) {
|
||||
return categoryService.delete(id);
|
||||
}
|
||||
|
||||
// 获取分类树
|
||||
export async function getCategoryTree() {
|
||||
return categoryService.getTree();
|
||||
}
|
||||
6
apps/web-finance/src/api/finance/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// 财务管理相关 API 导出
|
||||
|
||||
export * from './category';
|
||||
export * from './loan';
|
||||
export * from './person';
|
||||
export * from './transaction';
|
||||
52
apps/web-finance/src/api/finance/loan.ts
Normal file
@@ -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<Loan>) {
|
||||
return loanService.create(data);
|
||||
}
|
||||
|
||||
// 更新贷款
|
||||
export async function updateLoan(id: string, data: Partial<Loan>) {
|
||||
return loanService.update(id, data);
|
||||
}
|
||||
|
||||
// 删除贷款
|
||||
export async function deleteLoan(id: string) {
|
||||
return loanService.delete(id);
|
||||
}
|
||||
|
||||
// 添加还款记录
|
||||
export async function addLoanRepayment(loanId: string, repayment: Partial<LoanRepayment>) {
|
||||
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();
|
||||
}
|
||||
37
apps/web-finance/src/api/finance/person.ts
Normal file
@@ -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<Person>) {
|
||||
return personService.create(data);
|
||||
}
|
||||
|
||||
// 更新人员
|
||||
export async function updatePerson(id: string, data: Partial<Person>) {
|
||||
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);
|
||||
}
|
||||
64
apps/web-finance/src/api/finance/transaction.ts
Normal file
@@ -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<Transaction>) {
|
||||
return transactionService.create(data);
|
||||
}
|
||||
|
||||
// 更新交易
|
||||
export async function updateTransaction(id: string, data: Partial<Transaction>) {
|
||||
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);
|
||||
}
|
||||
1
apps/web-finance/src/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './core';
|
||||
170
apps/web-finance/src/api/mock/finance-data.ts
Normal file
@@ -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;
|
||||
}
|
||||
450
apps/web-finance/src/api/mock/finance-service.ts
Normal file
@@ -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<Category>(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<T>(items: T[], params: PageParams): PageResult<T> {
|
||||
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<PageResult<Category>> {
|
||||
const categories = await getAll<Category>(STORES.CATEGORIES);
|
||||
return paginate(categories, params || { page: 1, pageSize: 100 });
|
||||
},
|
||||
|
||||
async getDetail(id: string): Promise<Category | null> {
|
||||
return get<Category>(STORES.CATEGORIES, id);
|
||||
},
|
||||
|
||||
async create(data: Partial<Category>): Promise<Category> {
|
||||
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<Category>): Promise<Category> {
|
||||
const existing = await get<Category>(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<void> {
|
||||
await remove(STORES.CATEGORIES, id);
|
||||
},
|
||||
|
||||
async getTree(): Promise<Category[]> {
|
||||
const categories = await getAll<Category>(STORES.CATEGORIES);
|
||||
// 这里可以构建树形结构,暂时返回平铺数据
|
||||
return categories;
|
||||
},
|
||||
};
|
||||
|
||||
// Transaction API
|
||||
export const transactionService = {
|
||||
async getList(params: SearchParams): Promise<PageResult<Transaction>> {
|
||||
const transactions = await getAll<Transaction>(STORES.TRANSACTIONS);
|
||||
const filtered = filterTransactions(transactions, params);
|
||||
return paginate(filtered, params);
|
||||
},
|
||||
|
||||
async getDetail(id: string): Promise<Transaction | null> {
|
||||
return get<Transaction>(STORES.TRANSACTIONS, id);
|
||||
},
|
||||
|
||||
async create(data: Partial<Transaction>): Promise<Transaction> {
|
||||
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<Transaction>): Promise<Transaction> {
|
||||
const existing = await get<Transaction>(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<void> {
|
||||
await remove(STORES.TRANSACTIONS, id);
|
||||
},
|
||||
|
||||
async batchDelete(ids: string[]): Promise<void> {
|
||||
for (const id of ids) {
|
||||
await remove(STORES.TRANSACTIONS, id);
|
||||
}
|
||||
},
|
||||
|
||||
async getStatistics(params?: SearchParams): Promise<any> {
|
||||
const transactions = await getAll<Transaction>(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<ImportResult> {
|
||||
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<PageResult<Person>> {
|
||||
const persons = await getAll<Person>(STORES.PERSONS);
|
||||
return paginate(persons, params || { page: 1, pageSize: 100 });
|
||||
},
|
||||
|
||||
async getDetail(id: string): Promise<Person | null> {
|
||||
return get<Person>(STORES.PERSONS, id);
|
||||
},
|
||||
|
||||
async create(data: Partial<Person>): Promise<Person> {
|
||||
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<Person>): Promise<Person> {
|
||||
const existing = await get<Person>(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<void> {
|
||||
await remove(STORES.PERSONS, id);
|
||||
},
|
||||
|
||||
async search(keyword: string): Promise<Person[]> {
|
||||
const persons = await getAll<Person>(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<PageResult<Loan>> {
|
||||
const loans = await getAll<Loan>(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<Loan | null> {
|
||||
return get<Loan>(STORES.LOANS, id);
|
||||
},
|
||||
|
||||
async create(data: Partial<Loan>): Promise<Loan> {
|
||||
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<Loan>): Promise<Loan> {
|
||||
const existing = await get<Loan>(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<void> {
|
||||
await remove(STORES.LOANS, id);
|
||||
},
|
||||
|
||||
async addRepayment(loanId: string, repayment: Partial<LoanRepayment>): Promise<Loan> {
|
||||
const loan = await get<Loan>(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<Loan> {
|
||||
const loan = await get<Loan>(STORES.LOANS, id);
|
||||
if (!loan) {
|
||||
throw new Error('Loan not found');
|
||||
}
|
||||
loan.status = status;
|
||||
await update(STORES.LOANS, loan);
|
||||
return loan;
|
||||
},
|
||||
|
||||
async getStatistics(): Promise<any> {
|
||||
const loans = await getAll<Loan>(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,
|
||||
};
|
||||
},
|
||||
};
|
||||
113
apps/web-finance/src/api/request.ts
Normal file
@@ -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 });
|
||||
39
apps/web-finance/src/app.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useAntdDesignTokens } from '@vben/hooks';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
|
||||
import { App, ConfigProvider, theme } from 'ant-design-vue';
|
||||
|
||||
import { antdLocale } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'App' });
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const { tokens } = useAntdDesignTokens();
|
||||
|
||||
const tokenTheme = computed(() => {
|
||||
const algorithm = isDark.value
|
||||
? [theme.darkAlgorithm]
|
||||
: [theme.defaultAlgorithm];
|
||||
|
||||
// antd 紧凑模式算法
|
||||
if (preferences.app.compact) {
|
||||
algorithm.push(theme.compactAlgorithm);
|
||||
}
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
token: tokens,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
|
||||
<App>
|
||||
<RouterView :key="$route.fullPath" />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
93
apps/web-finance/src/bootstrap.ts
Normal file
@@ -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 };
|
||||
147
apps/web-finance/src/components/charts/useChart.ts
Normal file
@@ -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<HTMLDivElement | null>,
|
||||
options: UseChartOptions = {},
|
||||
) {
|
||||
const { theme = 'light', initOptions = {}, loading = false, loadingOptions = {} } = options;
|
||||
|
||||
let chartInstance: EChartsInstance | null = null;
|
||||
const cacheOptions = ref<EChartsOption>({});
|
||||
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,
|
||||
};
|
||||
}
|
||||
23
apps/web-finance/src/layouts/auth.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { AuthPageLayout } from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const appName = computed(() => preferences.app.name);
|
||||
const logo = computed(() => preferences.logo.source);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthPageLayout
|
||||
:app-name="appName"
|
||||
:logo="logo"
|
||||
:page-description="$t('authentication.pageDesc')"
|
||||
:page-title="$t('authentication.pageTitle')"
|
||||
>
|
||||
<!-- 自定义工具栏 -->
|
||||
<!-- <template #toolbar></template> -->
|
||||
</AuthPageLayout>
|
||||
</template>
|
||||
157
apps/web-finance/src/layouts/basic.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
|
||||
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
|
||||
import { useWatermark } from '@vben/hooks';
|
||||
import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
|
||||
import {
|
||||
BasicLayout,
|
||||
LockScreen,
|
||||
Notification,
|
||||
UserDropdown,
|
||||
} from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
import { useAuthStore } from '#/store';
|
||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
|
||||
const notifications = ref<NotificationItem[]>([
|
||||
{
|
||||
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
|
||||
date: '3小时前',
|
||||
isRead: true,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '收到了 14 份新周报',
|
||||
},
|
||||
{
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '刚刚',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '朱偏右 回复了你',
|
||||
},
|
||||
{
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '2024-01-01',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '曲丽丽 评论了你',
|
||||
},
|
||||
{
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '代办提醒',
|
||||
},
|
||||
]);
|
||||
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
const showDot = computed(() =>
|
||||
notifications.value.some((item) => !item.isRead),
|
||||
);
|
||||
|
||||
const menus = computed(() => [
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(VBEN_DOC_URL, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: BookOpenText,
|
||||
text: $t('ui.widgets.document'),
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(VBEN_GITHUB_URL, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: MdiGithub,
|
||||
text: 'GitHub',
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(`${VBEN_GITHUB_URL}/issues`, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: CircleHelp,
|
||||
text: $t('ui.widgets.qa'),
|
||||
},
|
||||
]);
|
||||
|
||||
const avatar = computed(() => {
|
||||
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout(false);
|
||||
}
|
||||
|
||||
function handleNoticeClear() {
|
||||
notifications.value = [];
|
||||
}
|
||||
|
||||
function handleMakeAll() {
|
||||
notifications.value.forEach((item) => (item.isRead = true));
|
||||
}
|
||||
watch(
|
||||
() => preferences.app.watermark,
|
||||
async (enable) => {
|
||||
if (enable) {
|
||||
await updateWatermark({
|
||||
content: `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
|
||||
});
|
||||
} else {
|
||||
destroyWatermark();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicLayout @clear-preferences-and-logout="handleLogout">
|
||||
<template #user-dropdown>
|
||||
<UserDropdown
|
||||
:avatar
|
||||
:menus
|
||||
:text="userStore.userInfo?.realName"
|
||||
description="ann.vben@gmail.com"
|
||||
tag-text="Pro"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</template>
|
||||
<template #notification>
|
||||
<Notification
|
||||
:dot="showDot"
|
||||
:notifications="notifications"
|
||||
@clear="handleNoticeClear"
|
||||
@make-all="handleMakeAll"
|
||||
/>
|
||||
</template>
|
||||
<template #extra>
|
||||
<AuthenticationLoginExpiredModal
|
||||
v-model:open="accessStore.loginExpired"
|
||||
:avatar
|
||||
>
|
||||
<LoginForm />
|
||||
</AuthenticationLoginExpiredModal>
|
||||
</template>
|
||||
<template #lock-screen>
|
||||
<LockScreen :avatar @to-login="handleLogout" />
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
6
apps/web-finance/src/layouts/index.ts
Normal file
@@ -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 };
|
||||
3
apps/web-finance/src/locales/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# locale
|
||||
|
||||
每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。
|
||||
102
apps/web-finance/src/locales/index.ts
Normal file
@@ -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<Locale>(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 };
|
||||
12
apps/web-finance/src/locales/langs/en-US/demos.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
14
apps/web-finance/src/locales/langs/en-US/page.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
42
apps/web-finance/src/locales/langs/zh-CN/analytics.json
Normal file
@@ -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": "类型"
|
||||
}
|
||||
12
apps/web-finance/src/locales/langs/zh-CN/demos.json
Normal file
@@ -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 版本"
|
||||
}
|
||||
}
|
||||
90
apps/web-finance/src/locales/langs/zh-CN/finance.json
Normal file
@@ -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": "暂无数据"
|
||||
}
|
||||
23
apps/web-finance/src/locales/langs/zh-CN/page.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
"register": "注册",
|
||||
"codeLogin": "验证码登录",
|
||||
"qrcodeLogin": "二维码登录",
|
||||
"forgetPassword": "忘记密码"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "概览",
|
||||
"analytics": "分析页",
|
||||
"workspace": "工作台"
|
||||
},
|
||||
"finance": {
|
||||
"title": "财务管理"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "统计分析"
|
||||
},
|
||||
"tools": {
|
||||
"title": "系统工具"
|
||||
}
|
||||
}
|
||||
62
apps/web-finance/src/locales/langs/zh-CN/tools.json
Normal file
@@ -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": "使用次数"
|
||||
}
|
||||
31
apps/web-finance/src/main.ts
Normal file
@@ -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();
|
||||
13
apps/web-finance/src/preferences.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineOverridesPreferences } from '@vben/preferences';
|
||||
|
||||
/**
|
||||
* @description 项目配置文件
|
||||
* 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
|
||||
* !!! 更改配置后请清空缓存,否则可能不生效
|
||||
*/
|
||||
export const overridesPreferences = defineOverridesPreferences({
|
||||
// overrides
|
||||
app: {
|
||||
name: import.meta.env.VITE_APP_TITLE,
|
||||
},
|
||||
});
|
||||
42
apps/web-finance/src/router/access.ts
Normal file
@@ -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 };
|
||||
133
apps/web-finance/src/router/guard.ts
Normal file
@@ -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<string>();
|
||||
|
||||
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 };
|
||||
37
apps/web-finance/src/router/index.ts
Normal file
@@ -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 };
|
||||
97
apps/web-finance/src/router/routes/core.ts
Normal file
@@ -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 };
|
||||
37
apps/web-finance/src/router/routes/index.ts
Normal file
@@ -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 };
|
||||
81
apps/web-finance/src/router/routes/modules/analytics.ts
Normal file
@@ -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;
|
||||
38
apps/web-finance/src/router/routes/modules/dashboard.ts
Normal file
@@ -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;
|
||||
28
apps/web-finance/src/router/routes/modules/demos.ts
Normal file
@@ -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;
|
||||
103
apps/web-finance/src/router/routes/modules/finance.ts
Normal file
@@ -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;
|
||||
66
apps/web-finance/src/router/routes/modules/tools.ts
Normal file
@@ -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;
|
||||
81
apps/web-finance/src/router/routes/modules/vben.ts
Normal file
@@ -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;
|
||||
118
apps/web-finance/src/store/auth.ts
Normal file
@@ -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<any>,
|
||||
onSuccess?: () => Promise<void> | 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,
|
||||
};
|
||||
});
|
||||
1
apps/web-finance/src/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './auth';
|
||||
166
apps/web-finance/src/store/modules/budget.ts
Normal file
@@ -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<Budget>(STORES.BUDGETS);
|
||||
this.budgets = budgets;
|
||||
} catch (error) {
|
||||
console.error('获取预算失败:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 创建预算
|
||||
async createBudget(budget: Partial<Budget>) {
|
||||
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<Budget>) {
|
||||
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)
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
93
apps/web-finance/src/store/modules/category.ts
Normal file
@@ -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<Category[]>([]);
|
||||
const categoryTree = ref<Category[]>([]);
|
||||
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<Category>) {
|
||||
const newCategory = await createCategoryApi(data);
|
||||
categories.value.push(newCategory);
|
||||
return newCategory;
|
||||
}
|
||||
|
||||
// 更新分类
|
||||
async function updateCategory(id: string, data: Partial<Category>) {
|
||||
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,
|
||||
};
|
||||
});
|
||||
142
apps/web-finance/src/store/modules/loan.ts
Normal file
@@ -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<Loan[]>([]);
|
||||
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<Loan>) {
|
||||
const newLoan = await createLoanApi(data);
|
||||
loans.value.push(newLoan);
|
||||
return newLoan;
|
||||
}
|
||||
|
||||
// 更新贷款
|
||||
async function updateLoan(id: string, data: Partial<Loan>) {
|
||||
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<LoanRepayment>) {
|
||||
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,
|
||||
};
|
||||
});
|
||||
91
apps/web-finance/src/store/modules/person.ts
Normal file
@@ -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<Person[]>([]);
|
||||
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<Person>) {
|
||||
const newPerson = await createPersonApi(data);
|
||||
persons.value.push(newPerson);
|
||||
return newPerson;
|
||||
}
|
||||
|
||||
// 更新人员
|
||||
async function updatePerson(id: string, data: Partial<Person>) {
|
||||
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,
|
||||
};
|
||||
});
|
||||
120
apps/web-finance/src/store/modules/tag.ts
Normal file
@@ -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<Tag>(STORES.TAGS);
|
||||
this.tags = tags;
|
||||
} catch (error) {
|
||||
console.error('获取标签失败:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 创建标签
|
||||
async createTag(tag: Partial<Tag>) {
|
||||
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<Tag>) {
|
||||
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
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
141
apps/web-finance/src/store/modules/transaction.ts
Normal file
@@ -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<Transaction[]>([]);
|
||||
const currentTransaction = ref<Transaction | null>(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<Transaction>) {
|
||||
const newTransaction = await createTransactionApi(data);
|
||||
transactions.value.unshift(newTransaction);
|
||||
return newTransaction;
|
||||
}
|
||||
|
||||
// 更新交易
|
||||
async function updateTransaction(id: string, data: Partial<Transaction>) {
|
||||
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<ImportResult> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
150
apps/web-finance/src/styles/mobile.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
175
apps/web-finance/src/types/finance.ts
Normal file
@@ -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<T> {
|
||||
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;
|
||||
}
|
||||
179
apps/web-finance/src/utils/data-migration.ts
Normal file
@@ -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<T>(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<any>(OLD_STORAGE_KEYS.CATEGORIES);
|
||||
const oldPersons = readOldData<any>(OLD_STORAGE_KEYS.PERSONS);
|
||||
const oldTransactions = readOldData<any>(OLD_STORAGE_KEYS.TRANSACTIONS);
|
||||
const oldLoans = readOldData<any>(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;
|
||||
}
|
||||
324
apps/web-finance/src/utils/db.ts
Normal file
@@ -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<IDBDatabase> {
|
||||
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<IDBDatabase> {
|
||||
if (!db) {
|
||||
db = await initDB();
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
// 通用的添加数据方法
|
||||
export async function add<T>(storeName: string, data: T): Promise<T> {
|
||||
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<T>(storeName: string, data: T): Promise<T> {
|
||||
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<void> {
|
||||
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<T>(storeName: string, id: string): Promise<T | null> {
|
||||
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<T>(storeName: string): Promise<T[]> {
|
||||
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<T>(
|
||||
storeName: string,
|
||||
indexName: string,
|
||||
value: any,
|
||||
): Promise<T[]> {
|
||||
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<void> {
|
||||
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<T>(storeName: string, dataList: T[]): Promise<void> {
|
||||
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<Transaction>(STORES.TRANSACTIONS);
|
||||
const categories = await getAll<Category>(STORES.CATEGORIES);
|
||||
const persons = await getAll<Person>(STORES.PERSONS);
|
||||
const loans = await getAll<Loan>(STORES.LOANS);
|
||||
|
||||
return {
|
||||
transactions,
|
||||
categories,
|
||||
persons,
|
||||
loans,
|
||||
};
|
||||
}
|
||||
|
||||
// 导入数据库
|
||||
export async function importDatabase(data: {
|
||||
transactions?: Transaction[];
|
||||
categories?: Category[];
|
||||
persons?: Person[];
|
||||
loans?: Loan[];
|
||||
}): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
199
apps/web-finance/src/utils/export.ts
Normal file
@@ -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<string, any>[] {
|
||||
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<string, any> = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || '';
|
||||
});
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
266
apps/web-finance/src/utils/import.ts
Normal file
@@ -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<string, any>[] {
|
||||
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<string, any>[] = [];
|
||||
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<string, any> = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index];
|
||||
});
|
||||
data.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入交易数据从CSV
|
||||
*/
|
||||
export function importTransactionsFromCSV(
|
||||
csvData: Record<string, any>[],
|
||||
categories: Category[],
|
||||
persons: Person[]
|
||||
): {
|
||||
transactions: Partial<Transaction>[],
|
||||
errors: string[],
|
||||
newCategories: string[],
|
||||
newPersons: string[]
|
||||
} {
|
||||
const transactions: Partial<Transaction>[] = [];
|
||||
const errors: string[] = [];
|
||||
const newCategories = new Set<string>();
|
||||
const newPersons = new Set<string>();
|
||||
|
||||
// 创建分类和人员的反向映射(名称到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<Transaction> = {
|
||||
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<string, string>();
|
||||
|
||||
// 处理分类
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
3
apps/web-finance/src/views/_core/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# \_core
|
||||
|
||||
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。
|
||||
9
apps/web-finance/src/views/_core/about/index.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { About } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'About' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<About />
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { AuthenticationCodeLogin, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'CodeLogin' });
|
||||
|
||||
const loading = ref(false);
|
||||
const CODE_LENGTH = 6;
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.mobile'),
|
||||
},
|
||||
fieldName: 'phoneNumber',
|
||||
label: $t('authentication.mobile'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.mobileTip') })
|
||||
.refine((v) => /^\d{11}$/.test(v), {
|
||||
message: $t('authentication.mobileErrortip'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
component: 'VbenPinInput',
|
||||
componentProps: {
|
||||
codeLength: CODE_LENGTH,
|
||||
createText: (countdown: number) => {
|
||||
const text =
|
||||
countdown > 0
|
||||
? $t('authentication.sendText', [countdown])
|
||||
: $t('authentication.sendCode');
|
||||
return text;
|
||||
},
|
||||
placeholder: $t('authentication.code'),
|
||||
},
|
||||
fieldName: 'code',
|
||||
label: $t('authentication.code'),
|
||||
rules: z.string().length(CODE_LENGTH, {
|
||||
message: $t('authentication.codeTip', [CODE_LENGTH]),
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
/**
|
||||
* 异步处理登录操作
|
||||
* Asynchronously handle the login process
|
||||
* @param values 登录表单数据
|
||||
*/
|
||||
async function handleLogin(values: Recordable<any>) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(values);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationCodeLogin
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleLogin"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { AuthenticationForgetPassword, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'ForgetPassword' });
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: 'example@example.com',
|
||||
},
|
||||
fieldName: 'email',
|
||||
label: $t('authentication.email'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.emailTip') })
|
||||
.email($t('authentication.emailValidErrorTip')),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit(value: Recordable<any>) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('reset email:', value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationForgetPassword
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
118
apps/web-finance/src/views/_core/authentication/login.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { BasicOption } from '@vben/types';
|
||||
|
||||
import { computed, markRaw, onMounted, ref } from 'vue';
|
||||
|
||||
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
defineOptions({ name: 'Login' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const loginFormRef = ref();
|
||||
|
||||
// 开发模式下自动登录
|
||||
onMounted(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
// 延迟执行,确保表单完全初始化
|
||||
setTimeout(() => {
|
||||
console.log('开发模式:自动执行登录');
|
||||
// 直接调用登录方法,使用默认的admin账号
|
||||
authStore.authLogin({
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
captcha: true, // 开发模式下跳过验证码
|
||||
selectAccount: 'admin',
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
const MOCK_USER_OPTIONS: BasicOption[] = [
|
||||
{
|
||||
label: 'Super',
|
||||
value: 'vben',
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
value: 'admin',
|
||||
},
|
||||
{
|
||||
label: 'User',
|
||||
value: 'jack',
|
||||
},
|
||||
];
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
options: MOCK_USER_OPTIONS,
|
||||
placeholder: $t('authentication.selectAccount'),
|
||||
},
|
||||
fieldName: 'selectAccount',
|
||||
label: $t('authentication.selectAccount'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('authentication.selectAccount') })
|
||||
.optional()
|
||||
.default('vben'),
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.usernameTip'),
|
||||
},
|
||||
dependencies: {
|
||||
trigger(values, form) {
|
||||
if (values.selectAccount) {
|
||||
const findUser = MOCK_USER_OPTIONS.find(
|
||||
(item) => item.value === values.selectAccount,
|
||||
);
|
||||
if (findUser) {
|
||||
form.setValues({
|
||||
password: '123456',
|
||||
username: findUser.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
triggerFields: ['selectAccount'],
|
||||
},
|
||||
fieldName: 'username',
|
||||
label: $t('authentication.username'),
|
||||
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.password'),
|
||||
},
|
||||
fieldName: 'password',
|
||||
label: $t('authentication.password'),
|
||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
||||
},
|
||||
{
|
||||
component: markRaw(SliderCaptcha),
|
||||
fieldName: 'captcha',
|
||||
rules: z.boolean().refine((value) => value, {
|
||||
message: $t('authentication.verifyRequiredTip'),
|
||||
}),
|
||||
// 开发模式下设置默认值为true,跳过验证
|
||||
...(import.meta.env.DEV ? { defaultValue: true } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationLogin
|
||||
:form-schema="formSchema"
|
||||
:loading="authStore.loginLoading"
|
||||
@submit="authStore.authLogin"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { AuthenticationQrCodeLogin } from '@vben/common-ui';
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
|
||||
defineOptions({ name: 'QrCodeLogin' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationQrCodeLogin :login-path="LOGIN_PATH" />
|
||||
</template>
|
||||
96
apps/web-finance/src/views/_core/authentication/register.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, h, ref } from 'vue';
|
||||
|
||||
import { AuthenticationRegister, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
defineOptions({ name: 'Register' });
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.usernameTip'),
|
||||
},
|
||||
fieldName: 'username',
|
||||
label: $t('authentication.username'),
|
||||
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: $t('authentication.password'),
|
||||
},
|
||||
fieldName: 'password',
|
||||
label: $t('authentication.password'),
|
||||
renderComponentContent() {
|
||||
return {
|
||||
strengthText: () => $t('authentication.passwordStrength'),
|
||||
};
|
||||
},
|
||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: $t('authentication.confirmPassword'),
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { password } = values;
|
||||
return z
|
||||
.string({ required_error: $t('authentication.passwordTip') })
|
||||
.min(1, { message: $t('authentication.passwordTip') })
|
||||
.refine((value) => value === password, {
|
||||
message: $t('authentication.confirmPasswordTip'),
|
||||
});
|
||||
},
|
||||
triggerFields: ['password'],
|
||||
},
|
||||
fieldName: 'confirmPassword',
|
||||
label: $t('authentication.confirmPassword'),
|
||||
},
|
||||
{
|
||||
component: 'VbenCheckbox',
|
||||
fieldName: 'agreePolicy',
|
||||
renderComponentContent: () => ({
|
||||
default: () =>
|
||||
h('span', [
|
||||
$t('authentication.agree'),
|
||||
h(
|
||||
'a',
|
||||
{
|
||||
class: 'vben-link ml-1 ',
|
||||
href: '',
|
||||
},
|
||||
`${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`,
|
||||
),
|
||||
]),
|
||||
}),
|
||||
rules: z.boolean().refine((value) => !!value, {
|
||||
message: $t('authentication.agreeTip'),
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit(value: Recordable<any>) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('register submit:', value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationRegister
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="coming-soon" />
|
||||
</template>
|
||||
9
apps/web-finance/src/views/_core/fallback/forbidden.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback403Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="403" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback500Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="500" />
|
||||
</template>
|
||||
9
apps/web-finance/src/views/_core/fallback/not-found.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback404Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="404" />
|
||||
</template>
|
||||
9
apps/web-finance/src/views/_core/fallback/offline.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'FallbackOfflineDemo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="offline" />
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="category-pie-chart">
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '#/components/charts/useChart';
|
||||
import type { Category, Transaction, TransactionType } from '#/types/finance';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useChart } from '#/components/charts/useChart';
|
||||
|
||||
interface Props {
|
||||
transactions: Transaction[];
|
||||
categories: Category[];
|
||||
type: TransactionType;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const { setOptions } = useChart(chartRef);
|
||||
|
||||
const chartData = computed(() => {
|
||||
// 统计各分类的金额
|
||||
const categoryMap = new Map<string, number>();
|
||||
const categoryNames = new Map<string, string>();
|
||||
|
||||
// 初始化分类名称映射
|
||||
props.categories.forEach(cat => {
|
||||
categoryNames.set(cat.id, cat.name);
|
||||
});
|
||||
|
||||
// 统计交易数据
|
||||
props.transactions
|
||||
.filter(t => t.type === props.type)
|
||||
.forEach(transaction => {
|
||||
const current = categoryMap.get(transaction.categoryId) || 0;
|
||||
categoryMap.set(transaction.categoryId, current + transaction.amount);
|
||||
});
|
||||
|
||||
// 转换为图表数据格式
|
||||
const data = Array.from(categoryMap.entries())
|
||||
.map(([categoryId, amount]) => ({
|
||||
name: categoryNames.get(categoryId) || '未知分类',
|
||||
value: amount,
|
||||
}))
|
||||
.filter(item => item.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
const chartOptions = computed<EChartsOption>(() => ({
|
||||
title: {
|
||||
text: props.type === 'income' ? '收入分类分布' : '支出分类分布',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: ¥{c} ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: '10%',
|
||||
type: 'scroll',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: props.type === 'income' ? '收入' : '支出',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['60%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center',
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '16',
|
||||
fontWeight: 'bold',
|
||||
formatter: '{b}\n¥{c}',
|
||||
},
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
data: chartData.value,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
watch(chartOptions, (options) => {
|
||||
setOptions(options);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setOptions(chartOptions.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.category-pie-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="monthly-comparison-chart">
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '#/components/charts/useChart';
|
||||
import type { Transaction } from '#/types/finance';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useChart } from '#/components/charts/useChart';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface Props {
|
||||
transactions: Transaction[];
|
||||
year: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const { setOptions } = useChart(chartRef);
|
||||
|
||||
const chartData = computed(() => {
|
||||
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
const incomeData = new Array(12).fill(0);
|
||||
const expenseData = new Array(12).fill(0);
|
||||
const netData = new Array(12).fill(0);
|
||||
|
||||
// 统计每月数据
|
||||
props.transactions.forEach(transaction => {
|
||||
const date = dayjs(transaction.date);
|
||||
if (date.year() === props.year) {
|
||||
const monthIndex = date.month(); // 0-11
|
||||
|
||||
if (transaction.type === 'income') {
|
||||
incomeData[monthIndex] += transaction.amount;
|
||||
} else {
|
||||
expenseData[monthIndex] += transaction.amount;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 计算净收入
|
||||
for (let i = 0; i < 12; i++) {
|
||||
netData[i] = incomeData[i] - expenseData[i];
|
||||
}
|
||||
|
||||
return {
|
||||
months,
|
||||
income: incomeData,
|
||||
expense: expenseData,
|
||||
net: netData,
|
||||
};
|
||||
});
|
||||
|
||||
const chartOptions = computed<EChartsOption>(() => ({
|
||||
title: {
|
||||
text: `${props.year}年月度收支对比`,
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
crossStyle: {
|
||||
color: '#999',
|
||||
},
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
let html = `<div style="font-weight: bold">${params[0].name}</div>`;
|
||||
params.forEach((item: any) => {
|
||||
const value = item.value.toFixed(2);
|
||||
const prefix = item.seriesName === '净收入' && item.value > 0 ? '+' : '';
|
||||
html += `<div>${item.marker} ${item.seriesName}: ${prefix}¥${value}</div>`;
|
||||
});
|
||||
return html;
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
dataView: { show: true, readOnly: false },
|
||||
magicType: { show: true, type: ['line', 'bar'] },
|
||||
restore: { show: true },
|
||||
saveAsImage: { show: true },
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['收入', '支出', '净收入'],
|
||||
top: 30,
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: chartData.value.months,
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '金额',
|
||||
axisLabel: {
|
||||
formatter: '¥{value}',
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '收入',
|
||||
type: 'bar',
|
||||
data: chartData.value.income,
|
||||
itemStyle: {
|
||||
color: '#52c41a',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '支出',
|
||||
type: 'bar',
|
||||
data: chartData.value.expense,
|
||||
itemStyle: {
|
||||
color: '#ff4d4f',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '净收入',
|
||||
type: 'line',
|
||||
data: chartData.value.net,
|
||||
itemStyle: {
|
||||
color: '#1890ff',
|
||||
},
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
},
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
watch(chartOptions, (options) => {
|
||||
setOptions(options);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setOptions(chartOptions.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monthly-comparison-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="person-analysis-chart">
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '#/components/charts/useChart';
|
||||
import type { Person, Transaction } from '#/types/finance';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useChart } from '#/components/charts/useChart';
|
||||
|
||||
interface Props {
|
||||
transactions: Transaction[];
|
||||
persons: Person[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const { setOptions } = useChart(chartRef);
|
||||
|
||||
const chartData = computed(() => {
|
||||
const personMap = new Map<string, { income: number; expense: number }>();
|
||||
const personNames = new Map<string, string>();
|
||||
|
||||
// 初始化人员名称映射
|
||||
props.persons.forEach(person => {
|
||||
personNames.set(person.name, person.name);
|
||||
});
|
||||
|
||||
// 统计交易数据
|
||||
props.transactions.forEach(transaction => {
|
||||
// 统计付款人数据
|
||||
if (transaction.payer) {
|
||||
const current = personMap.get(transaction.payer) || { income: 0, expense: 0 };
|
||||
if (transaction.type === 'expense') {
|
||||
current.expense += transaction.amount;
|
||||
}
|
||||
personMap.set(transaction.payer, current);
|
||||
}
|
||||
|
||||
// 统计收款人数据
|
||||
if (transaction.payee) {
|
||||
const current = personMap.get(transaction.payee) || { income: 0, expense: 0 };
|
||||
if (transaction.type === 'income') {
|
||||
current.income += transaction.amount;
|
||||
}
|
||||
personMap.set(transaction.payee, current);
|
||||
}
|
||||
});
|
||||
|
||||
// 计算总金额并排序
|
||||
const sortedData = Array.from(personMap.entries())
|
||||
.map(([name, data]) => ({
|
||||
name,
|
||||
income: data.income,
|
||||
expense: data.expense,
|
||||
total: data.income + data.expense,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, props.limit);
|
||||
|
||||
return {
|
||||
names: sortedData.map(item => item.name),
|
||||
income: sortedData.map(item => item.income),
|
||||
expense: sortedData.map(item => item.expense),
|
||||
};
|
||||
});
|
||||
|
||||
const chartOptions = computed<EChartsOption>(() => ({
|
||||
title: {
|
||||
text: '人员交易统计(前' + props.limit + '名)',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
let html = `<div style="font-weight: bold">${params[0].name}</div>`;
|
||||
params.forEach((item: any) => {
|
||||
html += `<div>${item.marker} ${item.seriesName}: ¥${item.value.toFixed(2)}</div>`;
|
||||
});
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['收入相关', '支出相关'],
|
||||
top: 30,
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.value.names,
|
||||
axisTick: {
|
||||
alignWithLabel: true,
|
||||
},
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: 30,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
formatter: '¥{value}',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '收入相关',
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: chartData.value.income,
|
||||
itemStyle: {
|
||||
color: '#52c41a',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '支出相关',
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: chartData.value.expense,
|
||||
itemStyle: {
|
||||
color: '#ff4d4f',
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
watch(chartOptions, (options) => {
|
||||
setOptions(options);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setOptions(chartOptions.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.person-analysis-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
164
apps/web-finance/src/views/analytics/components/TrendChart.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="trend-chart">
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '#/components/charts/useChart';
|
||||
import type { Transaction } from '#/types/finance';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useChart } from '#/components/charts/useChart';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface Props {
|
||||
transactions: Transaction[];
|
||||
dateRange: [string, string];
|
||||
groupBy?: 'day' | 'week' | 'month';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
groupBy: 'day',
|
||||
});
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const { setOptions } = useChart(chartRef);
|
||||
|
||||
const chartData = computed(() => {
|
||||
const [startDate, endDate] = props.dateRange;
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate);
|
||||
|
||||
// 生成日期序列
|
||||
const dates: string[] = [];
|
||||
const incomeMap = new Map<string, number>();
|
||||
const expenseMap = new Map<string, number>();
|
||||
|
||||
let current = start;
|
||||
while (current.isBefore(end) || current.isSame(end)) {
|
||||
const dateKey = getDateKey(current);
|
||||
dates.push(dateKey);
|
||||
incomeMap.set(dateKey, 0);
|
||||
expenseMap.set(dateKey, 0);
|
||||
|
||||
// 根据分组方式调整日期增量
|
||||
if (props.groupBy === 'day') {
|
||||
current = current.add(1, 'day');
|
||||
} else if (props.groupBy === 'week') {
|
||||
current = current.add(1, 'week');
|
||||
} else {
|
||||
current = current.add(1, 'month');
|
||||
}
|
||||
}
|
||||
|
||||
// 统计交易数据
|
||||
props.transactions.forEach((transaction) => {
|
||||
const date = dayjs(transaction.date);
|
||||
if (date.isAfter(start.subtract(1, 'day')) && date.isBefore(end.add(1, 'day'))) {
|
||||
const dateKey = getDateKey(date);
|
||||
|
||||
if (transaction.type === 'income') {
|
||||
incomeMap.set(dateKey, (incomeMap.get(dateKey) || 0) + transaction.amount);
|
||||
} else {
|
||||
expenseMap.set(dateKey, (expenseMap.get(dateKey) || 0) + transaction.amount);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dates: dates,
|
||||
income: dates.map(date => incomeMap.get(date) || 0),
|
||||
expense: dates.map(date => expenseMap.get(date) || 0),
|
||||
};
|
||||
});
|
||||
|
||||
function getDateKey(date: dayjs.Dayjs): string {
|
||||
if (props.groupBy === 'day') {
|
||||
return date.format('MM-DD');
|
||||
} else if (props.groupBy === 'week') {
|
||||
return `第${date.week()}周`;
|
||||
} else {
|
||||
return date.format('YYYY-MM');
|
||||
}
|
||||
}
|
||||
|
||||
const chartOptions = computed<EChartsOption>(() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
const date = params[0].name;
|
||||
let html = `<div style="font-weight: bold">${date}</div>`;
|
||||
params.forEach((item: any) => {
|
||||
html += `<div>${item.marker} ${item.seriesName}: ¥${item.value.toFixed(2)}</div>`;
|
||||
});
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['收入', '支出'],
|
||||
top: 0,
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.value.dates,
|
||||
axisTick: {
|
||||
alignWithLabel: true,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
formatter: '¥{value}',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '收入',
|
||||
type: 'bar',
|
||||
data: chartData.value.income,
|
||||
itemStyle: {
|
||||
color: '#52c41a',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '支出',
|
||||
type: 'bar',
|
||||
data: chartData.value.expense,
|
||||
itemStyle: {
|
||||
color: '#ff4d4f',
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
watch(chartOptions, (options) => {
|
||||
setOptions(options);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setOptions(chartOptions.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
166
apps/web-finance/src/views/analytics/overview/index.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageHeaderTitle>数据概览</PageHeaderTitle>
|
||||
</PageHeader>
|
||||
<PageMain>
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium">筛选条件</h3>
|
||||
<Button @click="handleRefresh" :loading="loading">
|
||||
<SyncOutlined class="mr-1" />
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
<Form layout="inline">
|
||||
<FormItem label="日期范围">
|
||||
<RangePicker
|
||||
v-model:value="dateRange"
|
||||
:format="'YYYY-MM-DD'"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
style="width: 300px"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="统计周期">
|
||||
<Select
|
||||
v-model:value="groupBy"
|
||||
style="width: 120px"
|
||||
@change="handleRefresh"
|
||||
>
|
||||
<SelectOption value="day">按天</SelectOption>
|
||||
<SelectOption value="week">按周</SelectOption>
|
||||
<SelectOption value="month">按月</SelectOption>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="24" class="mb-4">
|
||||
<Card title="收支趋势图">
|
||||
<TrendChart
|
||||
:transactions="transactions"
|
||||
:date-range="dateRangeStrings"
|
||||
:group-by="groupBy"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :span="12" class="mb-4">
|
||||
<Card title="收入分类分布">
|
||||
<CategoryPieChart
|
||||
:transactions="transactions"
|
||||
:categories="categories"
|
||||
type="income"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :span="12" class="mb-4">
|
||||
<Card title="支出分类分布">
|
||||
<CategoryPieChart
|
||||
:transactions="transactions"
|
||||
:categories="categories"
|
||||
type="expense"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :span="24" class="mb-4">
|
||||
<Card :title="`${currentYear}年月度收支对比`">
|
||||
<MonthlyComparisonChart
|
||||
:transactions="transactions"
|
||||
:year="currentYear"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :span="24">
|
||||
<Card title="人员交易分析">
|
||||
<PersonAnalysisChart
|
||||
:transactions="transactions"
|
||||
:persons="persons"
|
||||
:limit="15"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</PageMain>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Category, Person, Transaction } from '#/types/finance';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { Page, PageHeader, PageHeaderTitle, PageMain } from '@vben/common-ui';
|
||||
import { Card, Form, FormItem, Row, Col, Button, Select, SelectOption } from 'ant-design-vue';
|
||||
import { RangePicker } from 'ant-design-vue/es/date-picker';
|
||||
import { SyncOutlined } from '@ant-design/icons-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { categoryApi, personApi, transactionApi } from '#/api/finance';
|
||||
|
||||
import TrendChart from '../components/TrendChart.vue';
|
||||
import CategoryPieChart from '../components/CategoryPieChart.vue';
|
||||
import MonthlyComparisonChart from '../components/MonthlyComparisonChart.vue';
|
||||
import PersonAnalysisChart from '../components/PersonAnalysisChart.vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const transactions = ref<Transaction[]>([]);
|
||||
const categories = ref<Category[]>([]);
|
||||
const persons = ref<Person[]>([]);
|
||||
|
||||
const dateRange = ref<[Dayjs, Dayjs]>([
|
||||
dayjs().startOf('month'),
|
||||
dayjs().endOf('month'),
|
||||
]);
|
||||
|
||||
const groupBy = ref<'day' | 'week' | 'month'>('day');
|
||||
|
||||
const dateRangeStrings = computed<[string, string]>(() => [
|
||||
dateRange.value[0].format('YYYY-MM-DD'),
|
||||
dateRange.value[1].format('YYYY-MM-DD'),
|
||||
]);
|
||||
|
||||
const currentYear = computed(() => dayjs().year());
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 获取日期范围内的交易数据
|
||||
const [transResult, catResult, personResult] = await Promise.all([
|
||||
transactionApi.getList({
|
||||
page: 1,
|
||||
pageSize: 10000, // 获取所有数据用于统计
|
||||
startDate: dateRangeStrings.value[0],
|
||||
endDate: dateRangeStrings.value[1],
|
||||
}),
|
||||
categoryApi.getList({ page: 1, pageSize: 100 }),
|
||||
personApi.getList({ page: 1, pageSize: 100 }),
|
||||
]);
|
||||
|
||||
transactions.value = transResult.data.items;
|
||||
categories.value = catResult.data.items;
|
||||
persons.value = personResult.data.items;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateChange = () => {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
13
apps/web-finance/src/views/analytics/reports/custom.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { Card } from 'ant-design-vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Card title="自定义报表">
|
||||
<div class="text-center text-gray-500 py-20">
|
||||
页面开发中...
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
13
apps/web-finance/src/views/analytics/reports/daily.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { Card } from 'ant-design-vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Card title="日报表">
|
||||
<div class="text-center text-gray-500 py-20">
|
||||
页面开发中...
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
13
apps/web-finance/src/views/analytics/reports/monthly.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { Card } from 'ant-design-vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Card title="月报表">
|
||||
<div class="text-center text-gray-500 py-20">
|
||||
页面开发中...
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
13
apps/web-finance/src/views/analytics/reports/yearly.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { Card } from 'ant-design-vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Card title="年报表">
|
||||
<div class="text-center text-gray-500 py-20">
|
||||
页面开发中...
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
13
apps/web-finance/src/views/analytics/trends/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { Card } from 'ant-design-vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<Card title="趋势分析">
|
||||
<div class="text-center text-gray-500 py-20">
|
||||
页面开发中...
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
onMounted(() => {
|
||||
renderEcharts({
|
||||
grid: {
|
||||
bottom: 0,
|
||||
containLabel: true,
|
||||
left: '1%',
|
||||
right: '1%',
|
||||
top: '2 %',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
areaStyle: {},
|
||||
data: [
|
||||
111, 2000, 6000, 16_000, 33_333, 55_555, 64_000, 33_333, 18_000,
|
||||
36_000, 70_000, 42_444, 23_222, 13_000, 8000, 4000, 1200, 333, 222,
|
||||
111,
|
||||
],
|
||||
itemStyle: {
|
||||
color: '#5ab1ef',
|
||||
},
|
||||
smooth: true,
|
||||
type: 'line',
|
||||
},
|
||||
{
|
||||
areaStyle: {},
|
||||
data: [
|
||||
33, 66, 88, 333, 3333, 6200, 20_000, 3000, 1200, 13_000, 22_000,
|
||||
11_000, 2221, 1201, 390, 198, 60, 30, 22, 11,
|
||||
],
|
||||
itemStyle: {
|
||||
color: '#019680',
|
||||
},
|
||||
smooth: true,
|
||||
type: 'line',
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: '#019680',
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
trigger: 'axis',
|
||||
},
|
||||
// xAxis: {
|
||||
// axisTick: {
|
||||
// show: false,
|
||||
// },
|
||||
// boundaryGap: false,
|
||||
// data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`),
|
||||
// type: 'category',
|
||||
// },
|
||||
xAxis: {
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
boundaryGap: false,
|
||||
data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`),
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'solid',
|
||||
width: 1,
|
||||
},
|
||||
show: true,
|
||||
},
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
max: 80_000,
|
||||
splitArea: {
|
||||
show: true,
|
||||
},
|
||||
splitNumber: 4,
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" />
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts } = useEcharts(chartRef);
|
||||
|
||||
onMounted(() => {
|
||||
renderEcharts({
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: ['访问', '趋势'],
|
||||
},
|
||||
radar: {
|
||||
indicator: [
|
||||
{
|
||||
name: '网页',
|
||||
},
|
||||
{
|
||||
name: '移动端',
|
||||
},
|
||||
{
|
||||
name: 'Ipad',
|
||||
},
|
||||
{
|
||||
name: '客户端',
|
||||
},
|
||||
{
|
||||
name: '第三方',
|
||||
},
|
||||
{
|
||||
name: '其它',
|
||||
},
|
||||
],
|
||||
radius: '60%',
|
||||
splitNumber: 8,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
areaStyle: {
|
||||
opacity: 1,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'rgba(0,0,0,.2)',
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 10,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
itemStyle: {
|
||||
color: '#b6a2de',
|
||||
},
|
||||
name: '访问',
|
||||
value: [90, 50, 86, 40, 50, 20],
|
||||
},
|
||||
{
|
||||
itemStyle: {
|
||||
color: '#5ab1ef',
|
||||
},
|
||||
name: '趋势',
|
||||
value: [70, 75, 70, 76, 20, 85],
|
||||
},
|
||||
],
|
||||
itemStyle: {
|
||||
// borderColor: '#fff',
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
},
|
||||
symbolSize: 0,
|
||||
type: 'radar',
|
||||
},
|
||||
],
|
||||
tooltip: {},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EchartsUI ref="chartRef" />
|
||||
</template>
|
||||