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