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>
This commit is contained in:
你的用户名
2025-08-06 20:09:48 +08:00
parent b93e22c45a
commit 4b4616de1e
193 changed files with 17756 additions and 16 deletions

8
apps/web-finance/.env Normal file
View 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

View File

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

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

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

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
export * from './auth';
export * from './menu';
export * from './user';

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

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

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

View File

@@ -0,0 +1,6 @@
// 财务管理相关 API 导出
export * from './category';
export * from './loan';
export * from './person';
export * from './transaction';

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

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

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

View File

@@ -0,0 +1 @@
export * from './core';

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
# locale
每个app使用的国际化可能不同这里用于扩展国际化的功能例如扩展 dayjs、antd组件库的多语言切换以及app本身的国际化文件。

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

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

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

View 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": "类型"
}

View 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 版本"
}
}

View 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": "暂无数据"
}

View File

@@ -0,0 +1,23 @@
{
"auth": {
"login": "登录",
"register": "注册",
"codeLogin": "验证码登录",
"qrcodeLogin": "二维码登录",
"forgetPassword": "忘记密码"
},
"dashboard": {
"title": "概览",
"analytics": "分析页",
"workspace": "工作台"
},
"finance": {
"title": "财务管理"
},
"analytics": {
"title": "统计分析"
},
"tools": {
"title": "系统工具"
}
}

View 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": "使用次数"
}

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

View File

@@ -0,0 +1,13 @@
import { defineOverridesPreferences } from '@vben/preferences';
/**
* @description 项目配置文件
* 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
* !!! 更改配置后请清空缓存,否则可能不生效
*/
export const overridesPreferences = defineOverridesPreferences({
// overrides
app: {
name: import.meta.env.VITE_APP_TITLE,
},
});

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
export * from './auth';

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
# \_core
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { About } from '@vben/common-ui';
defineOptions({ name: 'About' });
</script>
<template>
<About />
</template>

View File

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

View File

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

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

View File

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

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

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
</script>
<template>
<Fallback status="coming-soon" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback403Demo' });
</script>
<template>
<Fallback status="403" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback500Demo' });
</script>
<template>
<Fallback status="500" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback404Demo' });
</script>
<template>
<Fallback status="404" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'FallbackOfflineDemo' });
</script>
<template>
<Fallback status="offline" />
</template>

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

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