chore: migrate to KT financial system

This commit is contained in:
woshiqp465
2025-11-04 16:06:44 +08:00
parent 2c0505b73d
commit f4cd0a5f22
289 changed files with 7362 additions and 41458 deletions

View File

@@ -1,5 +0,0 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

View File

@@ -1,18 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "vbenjs/vue-vben-admin" }
],
"commit": false,
"fixed": [["@vben-core/*", "@vben/*"]],
"snapshot": {
"prereleaseTemplate": "{tag}-{datetime}"
},
"privatePackages": { "version": true, "tag": true },
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View File

@@ -1,9 +0,0 @@
MIT License
Copyright (c) 2024-present, Vben
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,153 +0,0 @@
<div align="center">
<a href="https://github.com/anncwb/vue-vben-admin">
<img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp">
</a>
<br>
<br>
[![license](https://img.shields.io/github/license/anncwb/vue-vben-admin.svg)](LICENSE)
<h1>Vue Vben Admin</h1>
</div>
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=vbenjs_vue-vben-admin&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=vbenjs_vue-vben-admin) ![codeql](https://github.com/vbenjs/vue-vben-admin/actions/workflows/codeql.yml/badge.svg) ![build](https://github.com/vbenjs/vue-vben-admin/actions/workflows/build.yml/badge.svg) ![ci](https://github.com/vbenjs/vue-vben-admin/actions/workflows/ci.yml/badge.svg) ![deploy](https://github.com/vbenjs/vue-vben-admin/actions/workflows/deploy.yml/badge.svg)
**日本語** | [English](./README.md) | [中文](./README.zh-CN.md)
## 紹介
Vue Vben Adminは、最新の`vue3``vite``TypeScript`などの主流技術を使用して開発された、無料でオープンソースの中・後端テンプレートです。すぐに使える中・後端のフロントエンドソリューションとして、学習の参考にもなります。
## アップグレード通知
これは最新バージョン `5.0` であり、以前のバージョンとは互換性がありません。新しいプロジェクトを開始する場合は、最新バージョンを使用することをお勧めします。古いバージョンを表示したい場合は、[v2ブランチ](https://github.com/vbenjs/vue-vben-admin/tree/v2)を使用してください。
## 特徴
- **最新技術スタック**Vue 3やViteなどの最先端フロントエンド技術で開発
- **TypeScript**アプリケーション規模のJavaScriptのための言語
- **テーマ**:複数のテーマカラーが利用可能で、カスタマイズオプションも豊富
- **国際化**:完全な内蔵国際化サポート
- **権限管理**:動的ルートベースの権限生成ソリューションを内蔵
## プレビュー
- [Vben Admin](https://vben.pro/) - フルバージョンの中国語サイト
テストアカウントvben/123456
<div align="center">
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview1.png">
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview2.png">
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview3.png">
</div>
### Gitpodを使用
GitpodGitHub用の無料オンライン開発環境でプロジェクトを開き、すぐにコーディングを開始します。
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/vbenjs/vue-vben-admin)
## ドキュメント
[ドキュメント](https://doc.vben.pro/)
## インストールと使用
1. プロジェクトコードを取得
```bash
git clone https://github.com/vbenjs/vue-vben-admin.git
```
2. 依存関係のインストール
```bash
cd vue-vben-admin
npm i -g corepack
pnpm install
```
3. 実行
```bash
pnpm dev
```
4. ビルド
```bash
pnpm build
```
## 変更ログ
[CHANGELOG](https://github.com/vbenjs/vue-vben-admin/releases)
## 貢献方法
ご参加をお待ちしております![Issueを提出](https://github.com/anncwb/vue-vben-admin/issues/new/choose)するか、Pull Requestを送信してください。
**Pull Request プロセス:**
1. コードをフォーク
2. 自分のブランチを作成:`git checkout -b feat/xxxx`
3. 変更をコミット:`git commit -am 'feat(function): add xxxxx'`
4. ブランチをプッシュ:`git push origin feat/xxxx`
5. `pull request`を送信
## Git貢献提出規則
参考 [vue](https://github.com/vuejs/vue/blob/dev/.github/COMMIT_CONVENTION.md) 規則 ([Angular](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular))
- `feat` 新機能の追加
- `fix` 問題/バグの修正
- `style` コードスタイルに関連し、実行結果に影響しない
- `perf` 最適化/パフォーマンス向上
- `refactor` リファクタリング
- `revert` 変更の取り消し
- `test` テスト関連
- `docs` ドキュメント/注釈
- `chore` 依存関係の更新/スキャフォールディング設定の変更など
- `ci` 継続的インテグレーション
- `types` 型定義ファイルの変更
## ブラウザサポート
ローカル開発には `Chrome 80+` ブラウザを推奨します
モダンブラウザをサポートし、IEはサポートしません
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari |
| :-: | :-: | :-: | :-: |
| 最新2バージョン | 最新2バージョン | 最新2バージョン | 最新2バージョン |
## メンテナー
[@Vben](https://github.com/anncwb)
## スター歴史
[![Star History Chart](https://api.star-history.com/svg?repos=vbenjs/vue-vben-admin&type=Date)](https://star-history.com/#vbenjs/vue-vben-admin&Date)
## 寄付
このプロジェクトが役に立つと思われた場合、作者にコーヒーを一杯おごってサポートを示すことができます!
![donate](https://unpkg.com/@vbenjs/static-source@0.1.7/source/sponsor.png)
<a style="display: block;width: 100px;height: 50px;line-height: 50px; color: #fff;text-align: center; background: #408aed;border-radius: 4px;" href="https://www.paypal.com/paypalme/cvvben">Paypal Me</a>
## 貢献者
<a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors">
<img alt="Contributors" src="https://opencollective.com/vbenjs/contributors.svg?button=false" />
</a>
## Discord
- [Github Discussions](https://github.com/anncwb/vue-vben-admin/discussions)
## ライセンス
[MIT © Vben-2020](./LICENSE)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1,3 +1,3 @@
PORT=5320 PORT=5666
ACCESS_TOKEN_SECRET=access_token_secret ACCESS_TOKEN_SECRET=access_token_secret
REFRESH_TOKEN_SECRET=refresh_token_secret REFRESH_TOKEN_SECRET=refresh_token_secret

View File

@@ -13,3 +13,19 @@ $ pnpm run start
# production mode # production mode
$ pnpm run build $ pnpm run build
``` ```
## Telegram Webhook 集成
财务系统新增交易后可自动通知本地的 Telegram 机器人,默认会将交易数据通过以下 Webhook 发送:
- `http://192.168.9.28:8889/webhook/transaction`
- 认证密钥:`ktapp.cc`
如需自定义目标地址或密钥,可在运行前设置以下环境变量:
```bash
export TELEGRAM_WEBHOOK_URL="http://<bot-host>:8889/webhook/transaction"
export TELEGRAM_WEBHOOK_SECRET="自定义密钥"
```
也可以使用旧变量 `FINANCE_BOT_WEBHOOK_URL``FINANCE_BOT_WEBHOOK_SECRET` 进行兼容配置。

View File

@@ -0,0 +1,29 @@
import { getQuery } from 'h3';
import { fetchMediaMessages } from '~/utils/media-repository';
import { useResponseSuccess } from '~/utils/response';
export default defineEventHandler((event) => {
const query = getQuery(event);
const limit =
typeof query.limit === 'string' && query.limit.length > 0
? Number.parseInt(query.limit, 10)
: undefined;
const rawTypes = (query.types ?? query.type ?? query.fileType) as
| string
| undefined;
const fileTypes = rawTypes
? rawTypes
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0)
: undefined;
const messages = fetchMediaMessages({
limit,
fileTypes,
});
return useResponseSuccess(messages);
});

View File

@@ -0,0 +1,22 @@
import { getRouterParam } from 'h3';
import { getMediaMessageById } from '~/utils/media-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response';
export default defineEventHandler((event) => {
const idParam = getRouterParam(event, 'id');
const id = idParam ? Number.parseInt(idParam, 10) : NaN;
if (!Number.isInteger(id)) {
return useResponseError('媒体ID不合法', -1);
}
const media = getMediaMessageById(id);
if (!media) {
return useResponseError('未找到对应的媒体记录', -1);
}
return useResponseSuccess(media);
});

View File

@@ -0,0 +1,46 @@
import { createReadStream, existsSync, statSync } from 'node:fs';
import { basename } from 'pathe';
import {
getRouterParam,
sendStream,
setResponseHeader,
setResponseStatus,
} from 'h3';
import { getMediaMessageById } from '~/utils/media-repository';
import { useResponseError } from '~/utils/response';
export default defineEventHandler((event) => {
const idParam = getRouterParam(event, 'id');
const id = idParam ? Number.parseInt(idParam, 10) : NaN;
if (!Number.isInteger(id)) {
setResponseStatus(event, 400);
return useResponseError('媒体ID不合法', -1);
}
const media = getMediaMessageById(id);
if (!media) {
setResponseStatus(event, 404);
return useResponseError('未找到对应的媒体记录', -1);
}
if (!media.filePath || !existsSync(media.filePath)) {
setResponseStatus(event, 404);
return useResponseError('媒体文件不存在或已被移除', -1);
}
const fileStats = statSync(media.filePath);
setResponseHeader(event, 'Content-Type', media.mimeType ?? 'application/octet-stream');
setResponseHeader(
event,
'Content-Disposition',
`attachment; filename="${encodeURIComponent(media.fileName ?? basename(media.filePath))}"`,
);
setResponseHeader(event, 'Content-Length', `${fileStats.size}`);
return sendStream(event, createReadStream(media.filePath));
});

View File

@@ -0,0 +1,35 @@
import { getQuery } from 'h3';
import {
fetchTransactions,
type TransactionStatus,
} from '~/utils/finance-repository';
import { useResponseSuccess } from '~/utils/response';
const DEFAULT_STATUSES: TransactionStatus[] = [
'draft',
'pending',
'approved',
'rejected',
'paid',
];
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const includeDeleted = query.includeDeleted === 'true';
const type = query.type as string | undefined;
const rawStatuses = (query.statuses ?? query.status) as string | undefined;
const statuses = rawStatuses
? (rawStatuses
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0) as TransactionStatus[])
: DEFAULT_STATUSES;
const reimbursements = fetchTransactions({
includeDeleted,
type,
statuses,
});
return useResponseSuccess(reimbursements);
});

View File

@@ -0,0 +1,73 @@
import { readBody } from 'h3';
import {
createTransaction,
type TransactionStatus,
} from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response';
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
const DEFAULT_CURRENCY = 'CNY';
const DEFAULT_STATUS: TransactionStatus = 'pending';
const ALLOWED_STATUSES: TransactionStatus[] = [
'draft',
'pending',
'approved',
'rejected',
'paid',
];
export default defineEventHandler(async (event) => {
const body = await readBody(event);
if (!body?.amount || !body?.transactionDate) {
return useResponseError('缺少必填字段', -1);
}
const amount = Number(body.amount);
if (Number.isNaN(amount)) {
return useResponseError('金额格式不正确', -1);
}
const type =
(body.type as 'expense' | 'income' | 'transfer' | undefined) ?? 'expense';
const status =
(body.status as TransactionStatus | undefined) ?? DEFAULT_STATUS;
if (!ALLOWED_STATUSES.includes(status)) {
return useResponseError('状态值不合法', -1);
}
const reimbursement = createTransaction({
type,
amount,
currency: body.currency ?? DEFAULT_CURRENCY,
categoryId: body.categoryId ?? null,
accountId: body.accountId ?? null,
transactionDate: body.transactionDate,
description:
body.description ??
body.item ??
(body.notes ? `${body.notes}` : '') ??
'',
project: body.project ?? body.category ?? null,
memo: body.memo ?? body.notes ?? null,
status,
reimbursementBatch: body.reimbursementBatch ?? null,
reviewNotes: body.reviewNotes ?? null,
submittedBy: body.submittedBy ?? body.requester ?? null,
approvedBy: body.approvedBy ?? null,
approvedAt: body.approvedAt ?? null,
statusUpdatedAt: body.statusUpdatedAt ?? undefined,
});
notifyTransactionWebhook(reimbursement, {
action: 'reimbursement.created',
}).catch((error) =>
console.error(
'[finance][reimbursements.post] webhook notify failed',
error,
),
);
return useResponseSuccess(reimbursement);
});

View File

@@ -0,0 +1,85 @@
import { getRouterParam, readBody } from 'h3';
import {
restoreTransaction,
updateTransaction,
type TransactionStatus,
} from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response';
const ALLOWED_STATUSES: TransactionStatus[] = [
'draft',
'pending',
'approved',
'rejected',
'paid',
];
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'));
if (Number.isNaN(id)) {
return useResponseError('参数错误', -1);
}
const body = await readBody(event);
if (body?.isDeleted === false) {
const restored = restoreTransaction(id);
if (!restored) {
return useResponseError('报销单不存在', -1);
}
return useResponseSuccess(restored);
}
const payload: Record<string, unknown> = {};
if (body?.type) payload.type = body.type;
if (body?.amount !== undefined) {
const amount = Number(body.amount);
if (Number.isNaN(amount)) {
return useResponseError('金额格式不正确', -1);
}
payload.amount = amount;
}
if (body?.currency) payload.currency = body.currency;
if (body?.categoryId !== undefined)
payload.categoryId = body.categoryId ?? null;
if (body?.accountId !== undefined) payload.accountId = body.accountId ?? null;
if (body?.transactionDate) payload.transactionDate = body.transactionDate;
if (body?.description !== undefined)
payload.description = body.description ?? '';
if (body?.project !== undefined) payload.project = body.project ?? null;
if (body?.memo !== undefined) payload.memo = body.memo ?? null;
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
if (body?.status !== undefined) {
const status = body.status as TransactionStatus;
if (!ALLOWED_STATUSES.includes(status)) {
return useResponseError('状态值不合法', -1);
}
payload.status = status;
}
if (body?.statusUpdatedAt !== undefined) {
payload.statusUpdatedAt = body.statusUpdatedAt;
}
if (body?.reimbursementBatch !== undefined) {
payload.reimbursementBatch = body.reimbursementBatch ?? null;
}
if (body?.reviewNotes !== undefined) {
payload.reviewNotes = body.reviewNotes ?? null;
}
if (body?.submittedBy !== undefined) {
payload.submittedBy = body.submittedBy ?? null;
}
if (body?.approvedBy !== undefined) {
payload.approvedBy = body.approvedBy ?? null;
}
if (body?.approvedAt !== undefined) {
payload.approvedAt = body.approvedAt ?? null;
}
const updated = updateTransaction(id, payload);
if (!updated) {
return useResponseError('报销单不存在', -1);
}
return useResponseSuccess(updated);
});

View File

@@ -1,11 +1,28 @@
import { getQuery } from 'h3'; import { getQuery } from 'h3';
import { fetchTransactions } from '~/utils/finance-repository'; import {
fetchTransactions,
type TransactionStatus,
} from '~/utils/finance-repository';
import { useResponseSuccess } from '~/utils/response'; import { useResponseSuccess } from '~/utils/response';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const query = getQuery(event); const query = getQuery(event);
const type = query.type as string | undefined; const type = query.type as string | undefined;
const transactions = fetchTransactions({ type }); const includeDeleted = query.includeDeleted === 'true';
const rawStatuses = (query.statuses ?? query.status) as
| string
| undefined;
const statuses = rawStatuses
? (rawStatuses
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0) as TransactionStatus[])
: (['approved', 'paid'] satisfies TransactionStatus[]);
const transactions = fetchTransactions({
type,
includeDeleted,
statuses,
});
return useResponseSuccess(transactions); return useResponseSuccess(transactions);
}); });

View File

@@ -1,8 +1,19 @@
import { readBody } from 'h3'; import { readBody } from 'h3';
import { createTransaction } from '~/utils/finance-repository'; import {
createTransaction,
type TransactionStatus,
} from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response'; import { useResponseError, useResponseSuccess } from '~/utils/response';
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
const DEFAULT_CURRENCY = 'CNY'; const DEFAULT_CURRENCY = 'CNY';
const ALLOWED_STATUSES: TransactionStatus[] = [
'draft',
'pending',
'approved',
'rejected',
'paid',
];
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await readBody(event); const body = await readBody(event);
@@ -16,6 +27,12 @@ export default defineEventHandler(async (event) => {
return useResponseError('金额格式不正确', -1); return useResponseError('金额格式不正确', -1);
} }
const status =
(body.status as TransactionStatus | undefined) ?? 'approved';
if (!ALLOWED_STATUSES.includes(status)) {
return useResponseError('状态值不合法', -1);
}
const transaction = createTransaction({ const transaction = createTransaction({
type: body.type, type: body.type,
amount, amount,
@@ -26,7 +43,18 @@ export default defineEventHandler(async (event) => {
description: body.description ?? '', description: body.description ?? '',
project: body.project ?? null, project: body.project ?? null,
memo: body.memo ?? null, memo: body.memo ?? null,
status,
reimbursementBatch: body.reimbursementBatch ?? null,
reviewNotes: body.reviewNotes ?? null,
submittedBy: body.submittedBy ?? null,
approvedBy: body.approvedBy ?? null,
statusUpdatedAt: body.statusUpdatedAt ?? undefined,
approvedAt: body.approvedAt ?? undefined,
}); });
notifyTransactionWebhook(transaction, { action: 'created' }).catch((error) =>
console.error('[finance][transactions.post] webhook notify failed', error),
);
return useResponseSuccess(transaction); return useResponseSuccess(transaction);
}); });

View File

@@ -2,9 +2,18 @@ import { getRouterParam, readBody } from 'h3';
import { import {
restoreTransaction, restoreTransaction,
updateTransaction, updateTransaction,
type TransactionStatus,
} from '~/utils/finance-repository'; } from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response'; import { useResponseError, useResponseSuccess } from '~/utils/response';
const ALLOWED_STATUSES: TransactionStatus[] = [
'draft',
'pending',
'approved',
'rejected',
'paid',
];
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id')); const id = Number(getRouterParam(event, 'id'));
if (Number.isNaN(id)) { if (Number.isNaN(id)) {
@@ -41,6 +50,31 @@ export default defineEventHandler(async (event) => {
if (body?.project !== undefined) payload.project = body.project ?? null; if (body?.project !== undefined) payload.project = body.project ?? null;
if (body?.memo !== undefined) payload.memo = body.memo ?? null; if (body?.memo !== undefined) payload.memo = body.memo ?? null;
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted; if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
if (body?.status !== undefined) {
const status = body.status as TransactionStatus;
if (!ALLOWED_STATUSES.includes(status)) {
return useResponseError('状态值不合法', -1);
}
payload.status = status;
}
if (body?.statusUpdatedAt !== undefined) {
payload.statusUpdatedAt = body.statusUpdatedAt;
}
if (body?.reimbursementBatch !== undefined) {
payload.reimbursementBatch = body.reimbursementBatch ?? null;
}
if (body?.reviewNotes !== undefined) {
payload.reviewNotes = body.reviewNotes ?? null;
}
if (body?.submittedBy !== undefined) {
payload.submittedBy = body.submittedBy ?? null;
}
if (body?.approvedBy !== undefined) {
payload.approvedBy = body.approvedBy ?? null;
}
if (body?.approvedAt !== undefined) {
payload.approvedAt = body.approvedAt ?? null;
}
const updated = updateTransaction(id, payload); const updated = updateTransaction(id, payload);
if (!updated) { if (!updated) {

BIN
apps/backend/backend.tar.gz Normal file

Binary file not shown.

View File

@@ -42,6 +42,24 @@ fs.mkdirSync(storeDir, { recursive: true });
const dbFile = path.join(storeDir, 'finance.db'); const dbFile = path.join(storeDir, 'finance.db');
const db = new Database(dbFile); const db = new Database(dbFile);
function assertIdentifier(name) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new Error(`Invalid identifier: ${name}`);
}
return name;
}
function ensureColumn(table, column, definition) {
const safeTable = assertIdentifier(table);
const safeColumn = assertIdentifier(column);
const columns = db
.prepare(`PRAGMA table_info(${safeTable})`)
.all()
.map((item) => item.name);
if (!columns.includes(safeColumn)) {
db.exec(`ALTER TABLE ${safeTable} ADD COLUMN ${definition}`);
}
}
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.exec(` db.exec(`
@@ -106,11 +124,38 @@ db.exec(`
project TEXT, project TEXT,
memo TEXT, memo TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'approved',
status_updated_at TEXT,
reimbursement_batch TEXT,
review_notes TEXT,
submitted_by TEXT,
approved_by TEXT,
approved_at TEXT,
is_deleted INTEGER NOT NULL DEFAULT 0, is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT deleted_at TEXT
); );
`); `);
ensureColumn(
'finance_transactions',
'status',
"status TEXT NOT NULL DEFAULT 'approved'",
);
ensureColumn(
'finance_transactions',
'status_updated_at',
'status_updated_at TEXT',
);
ensureColumn(
'finance_transactions',
'reimbursement_batch',
'reimbursement_batch TEXT',
);
ensureColumn('finance_transactions', 'review_notes', 'review_notes TEXT');
ensureColumn('finance_transactions', 'submitted_by', 'submitted_by TEXT');
ensureColumn('finance_transactions', 'approved_by', 'approved_by TEXT');
ensureColumn('finance_transactions', 'approved_at', 'approved_at TEXT');
const RAW_TEXT = fs.readFileSync(inputPath, 'utf8').replace(/^\uFEFF/, ''); const RAW_TEXT = fs.readFileSync(inputPath, 'utf8').replace(/^\uFEFF/, '');
const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0); const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0);
if (lines.length <= 1) { if (lines.length <= 1) {

View File

@@ -5,13 +5,39 @@ import {
MOCK_CURRENCIES, MOCK_CURRENCIES,
MOCK_EXCHANGE_RATES, MOCK_EXCHANGE_RATES,
} from './mock-data'; } from './mock-data';
import db from './sqlite';
export function listAccounts() { export function listAccounts() {
return MOCK_ACCOUNTS; return MOCK_ACCOUNTS;
} }
export function listCategories() { export function listCategories() {
return MOCK_CATEGORIES; // 从数据库读取分类
try {
const stmt = db.prepare(`
SELECT id, name, type, icon, color, user_id as userId, is_active as isActive
FROM finance_categories
WHERE is_active = 1
ORDER BY type, id
`);
const categories = stmt.all() as any[];
// 转换为前端需要的格式
return categories.map(cat => ({
id: cat.id,
userId: cat.userId,
name: cat.name,
type: cat.type,
icon: cat.icon,
color: cat.color,
sortOrder: cat.id,
isSystem: true,
isActive: Boolean(cat.isActive),
}));
} catch (error) {
console.error('从数据库读取分类失败使用MOCK数据:', error);
return MOCK_CATEGORIES;
}
} }
export function listBudgets() { export function listBudgets() {
@@ -27,29 +53,78 @@ export function listExchangeRates() {
} }
export function createCategoryRecord(category: any) { export function createCategoryRecord(category: any) {
const newCategory = { try {
...category, const stmt = db.prepare(`
id: MOCK_CATEGORIES.length + 1, INSERT INTO finance_categories (name, type, icon, color, user_id, is_active)
createdAt: new Date().toISOString(), VALUES (?, ?, ?, ?, ?, 1)
}; `);
MOCK_CATEGORIES.push(newCategory); const result = stmt.run(
return newCategory; category.name,
category.type,
category.icon || '📝',
category.color || '#dfe4ea',
category.userId || 1
);
return {
id: result.lastInsertRowid,
...category,
createdAt: new Date().toISOString(),
};
} catch (error) {
console.error('创建分类失败:', error);
return null;
}
} }
export function updateCategoryRecord(id: number, category: any) { export function updateCategoryRecord(id: number, category: any) {
const index = MOCK_CATEGORIES.findIndex((c) => c.id === id); try {
if (index !== -1) { const updates: string[] = [];
MOCK_CATEGORIES[index] = { ...MOCK_CATEGORIES[index], ...category }; const params: any[] = [];
return MOCK_CATEGORIES[index];
if (category.name) {
updates.push('name = ?');
params.push(category.name);
}
if (category.icon) {
updates.push('icon = ?');
params.push(category.icon);
}
if (category.color) {
updates.push('color = ?');
params.push(category.color);
}
if (updates.length === 0) return null;
params.push(id);
const stmt = db.prepare(`
UPDATE finance_categories
SET ${updates.join(', ')}
WHERE id = ?
`);
stmt.run(...params);
// 返回更新后的分类
const selectStmt = db.prepare('SELECT * FROM finance_categories WHERE id = ?');
return selectStmt.get(id);
} catch (error) {
console.error('更新分类失败:', error);
return null;
} }
return null;
} }
export function deleteCategoryRecord(id: number) { export function deleteCategoryRecord(id: number) {
const index = MOCK_CATEGORIES.findIndex((c) => c.id === id); try {
if (index !== -1) { // 软删除
MOCK_CATEGORIES.splice(index, 1); const stmt = db.prepare(`
UPDATE finance_categories
SET is_active = 0
WHERE id = ?
`);
stmt.run(id);
return true; return true;
} catch (error) {
console.error('删除分类失败:', error);
return false;
} }
return false;
} }

View File

@@ -16,6 +16,13 @@ interface TransactionRow {
project: null | string; project: null | string;
memo: null | string; memo: null | string;
created_at: string; created_at: string;
status: string;
status_updated_at: null | string;
reimbursement_batch: null | string;
review_notes: null | string;
submitted_by: null | string;
approved_by: null | string;
approved_at: null | string;
is_deleted: number; is_deleted: number;
deleted_at: null | string; deleted_at: null | string;
} }
@@ -32,8 +39,22 @@ interface TransactionPayload {
memo?: null | string; memo?: null | string;
createdAt?: string; createdAt?: string;
isDeleted?: boolean; isDeleted?: boolean;
status?: TransactionStatus;
statusUpdatedAt?: string;
reimbursementBatch?: null | string;
reviewNotes?: null | string;
submittedBy?: null | string;
approvedBy?: null | string;
approvedAt?: null | string;
} }
export type TransactionStatus =
| 'draft'
| 'pending'
| 'approved'
| 'rejected'
| 'paid';
function getExchangeRateToBase(currency: string) { function getExchangeRateToBase(currency: string) {
if (currency === BASE_CURRENCY) { if (currency === BASE_CURRENCY) {
return 1; return 1;
@@ -49,11 +70,11 @@ function mapTransaction(row: TransactionRow) {
return { return {
id: row.id, id: row.id,
userId: 1, userId: 1,
type: row.type as 'expense' | 'income' | 'transfer', type: 'expense' as const,
amount: row.amount, amount: Math.abs(row.amount),
currency: row.currency, currency: row.currency,
exchangeRateToBase: row.exchange_rate_to_base, exchangeRateToBase: row.exchange_rate_to_base,
amountInBase: row.amount_in_base, amountInBase: Math.abs(row.amount_in_base),
categoryId: row.category_id ?? undefined, categoryId: row.category_id ?? undefined,
accountId: row.account_id ?? undefined, accountId: row.account_id ?? undefined,
transactionDate: row.transaction_date, transactionDate: row.transaction_date,
@@ -61,13 +82,24 @@ function mapTransaction(row: TransactionRow) {
project: row.project ?? undefined, project: row.project ?? undefined,
memo: row.memo ?? undefined, memo: row.memo ?? undefined,
createdAt: row.created_at, createdAt: row.created_at,
status: row.status as TransactionStatus,
statusUpdatedAt: row.status_updated_at ?? undefined,
reimbursementBatch: row.reimbursement_batch ?? undefined,
reviewNotes: row.review_notes ?? undefined,
submittedBy: row.submitted_by ?? undefined,
approvedBy: row.approved_by ?? undefined,
approvedAt: row.approved_at ?? undefined,
isDeleted: Boolean(row.is_deleted), isDeleted: Boolean(row.is_deleted),
deletedAt: row.deleted_at ?? undefined, deletedAt: row.deleted_at ?? undefined,
}; };
} }
export function fetchTransactions( export function fetchTransactions(
options: { includeDeleted?: boolean; type?: string } = {}, options: {
includeDeleted?: boolean;
type?: string;
statuses?: TransactionStatus[];
} = {},
) { ) {
const clauses: string[] = []; const clauses: string[] = [];
const params: Record<string, unknown> = {}; const params: Record<string, unknown> = {};
@@ -79,11 +111,19 @@ export function fetchTransactions(
clauses.push('type = @type'); clauses.push('type = @type');
params.type = options.type; params.type = options.type;
} }
if (options.statuses && options.statuses.length > 0) {
clauses.push(
`status IN (${options.statuses.map((_, index) => `@status${index}`).join(', ')})`,
);
options.statuses.forEach((status, index) => {
params[`status${index}`] = status;
});
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const stmt = db.prepare<TransactionRow>( const stmt = db.prepare<TransactionRow>(
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted, deleted_at FROM finance_transactions ${where} ORDER BY transaction_date DESC, id DESC`, `SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted, deleted_at FROM finance_transactions ${where} ORDER BY transaction_date DESC, id DESC`,
); );
return stmt.all(params).map(mapTransaction); return stmt.all(params).map(mapTransaction);
@@ -91,7 +131,7 @@ export function fetchTransactions(
export function getTransactionById(id: number) { export function getTransactionById(id: number) {
const stmt = db.prepare<TransactionRow>( const stmt = db.prepare<TransactionRow>(
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted, deleted_at FROM finance_transactions WHERE id = ?`, `SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted, deleted_at FROM finance_transactions WHERE id = ?`,
); );
const row = stmt.get(id); const row = stmt.get(id);
return row ? mapTransaction(row) : null; return row ? mapTransaction(row) : null;
@@ -104,9 +144,20 @@ export function createTransaction(payload: TransactionPayload) {
payload.createdAt && payload.createdAt.length > 0 payload.createdAt && payload.createdAt.length > 0
? payload.createdAt ? payload.createdAt
: new Date().toISOString(); : new Date().toISOString();
const status: TransactionStatus = payload.status ?? 'approved';
const statusUpdatedAt =
payload.statusUpdatedAt && payload.statusUpdatedAt.length > 0
? payload.statusUpdatedAt
: createdAt;
const approvedAt =
payload.approvedAt && payload.approvedAt.length > 0
? payload.approvedAt
: status === 'approved' || status === 'paid'
? statusUpdatedAt
: null;
const stmt = db.prepare( const stmt = db.prepare(
`INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)`, `INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, @status, @statusUpdatedAt, @reimbursementBatch, @reviewNotes, @submittedBy, @approvedBy, @approvedAt, 0)`,
); );
const info = stmt.run({ const info = stmt.run({
@@ -122,6 +173,13 @@ export function createTransaction(payload: TransactionPayload) {
project: payload.project ?? null, project: payload.project ?? null,
memo: payload.memo ?? null, memo: payload.memo ?? null,
createdAt, createdAt,
status,
statusUpdatedAt,
reimbursementBatch: payload.reimbursementBatch ?? null,
reviewNotes: payload.reviewNotes ?? null,
submittedBy: payload.submittedBy ?? null,
approvedBy: payload.approvedBy ?? null,
approvedAt,
}); });
return getTransactionById(Number(info.lastInsertRowid)); return getTransactionById(Number(info.lastInsertRowid));
@@ -133,6 +191,25 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
return null; return null;
} }
const nextStatus = (payload.status ?? current.status ?? 'approved') as TransactionStatus;
const statusChanged = nextStatus !== current.status;
const statusUpdatedAt =
payload.statusUpdatedAt && payload.statusUpdatedAt.length > 0
? payload.statusUpdatedAt
: statusChanged
? new Date().toISOString()
: current.statusUpdatedAt ?? current.createdAt;
const approvedAt =
payload.approvedAt && payload.approvedAt.length > 0
? payload.approvedAt
: nextStatus === 'approved' || nextStatus === 'paid'
? current.approvedAt ?? (statusChanged ? statusUpdatedAt : null)
: null;
const approvedBy =
nextStatus === 'approved' || nextStatus === 'paid'
? payload.approvedBy ?? current.approvedBy ?? null
: payload.approvedBy ?? null;
const next = { const next = {
type: payload.type ?? current.type, type: payload.type ?? current.type,
amount: payload.amount ?? current.amount, amount: payload.amount ?? current.amount,
@@ -144,13 +221,21 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
project: payload.project ?? current.project ?? null, project: payload.project ?? current.project ?? null,
memo: payload.memo ?? current.memo ?? null, memo: payload.memo ?? current.memo ?? null,
isDeleted: payload.isDeleted ?? current.isDeleted, isDeleted: payload.isDeleted ?? current.isDeleted,
status: nextStatus,
statusUpdatedAt,
reimbursementBatch:
payload.reimbursementBatch ?? current.reimbursementBatch ?? null,
reviewNotes: payload.reviewNotes ?? current.reviewNotes ?? null,
submittedBy: payload.submittedBy ?? current.submittedBy ?? null,
approvedBy,
approvedAt,
}; };
const exchangeRate = getExchangeRateToBase(next.currency); const exchangeRate = getExchangeRateToBase(next.currency);
const amountInBase = +(next.amount * exchangeRate).toFixed(2); const amountInBase = +(next.amount * exchangeRate).toFixed(2);
const stmt = db.prepare( const stmt = db.prepare(
`UPDATE finance_transactions SET type = @type, amount = @amount, currency = @currency, exchange_rate_to_base = @exchangeRateToBase, amount_in_base = @amountInBase, category_id = @categoryId, account_id = @accountId, transaction_date = @transactionDate, description = @description, project = @project, memo = @memo, is_deleted = @isDeleted, deleted_at = @deletedAt WHERE id = @id`, `UPDATE finance_transactions SET type = @type, amount = @amount, currency = @currency, exchange_rate_to_base = @exchangeRateToBase, amount_in_base = @amountInBase, category_id = @categoryId, account_id = @accountId, transaction_date = @transactionDate, description = @description, project = @project, memo = @memo, status = @status, status_updated_at = @statusUpdatedAt, reimbursement_batch = @reimbursementBatch, review_notes = @reviewNotes, submitted_by = @submittedBy, approved_by = @approvedBy, approved_at = @approvedAt, is_deleted = @isDeleted, deleted_at = @deletedAt WHERE id = @id`,
); );
const deletedAt = next.isDeleted ? new Date().toISOString() : null; const deletedAt = next.isDeleted ? new Date().toISOString() : null;
@@ -168,6 +253,13 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
description: next.description, description: next.description,
project: next.project, project: next.project,
memo: next.memo, memo: next.memo,
status: next.status,
statusUpdatedAt: next.statusUpdatedAt,
reimbursementBatch: next.reimbursementBatch,
reviewNotes: next.reviewNotes,
submittedBy: next.submittedBy,
approvedBy: next.approvedBy,
approvedAt: next.approvedAt,
isDeleted: next.isDeleted ? 1 : 0, isDeleted: next.isDeleted ? 1 : 0,
deletedAt, deletedAt,
}); });
@@ -203,12 +295,20 @@ export function replaceAllTransactions(
project?: null | string; project?: null | string;
transactionDate: string; transactionDate: string;
type: string; type: string;
status?: TransactionStatus;
statusUpdatedAt?: string;
reimbursementBatch?: null | string;
reviewNotes?: null | string;
submittedBy?: null | string;
approvedBy?: null | string;
approvedAt?: null | string;
isDeleted?: boolean;
}>, }>,
) { ) {
db.prepare('DELETE FROM finance_transactions').run(); db.prepare('DELETE FROM finance_transactions').run();
const insert = db.prepare( const insert = db.prepare(
`INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)`, `INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, @status, @statusUpdatedAt, @reimbursementBatch, @reviewNotes, @submittedBy, @approvedBy, @approvedAt, @isDeleted)`,
); );
const getRate = db.prepare( const getRate = db.prepare(
@@ -220,15 +320,36 @@ export function replaceAllTransactions(
const row = getRate.get(item.currency) as undefined | { rate: number }; const row = getRate.get(item.currency) as undefined | { rate: number };
const rate = row?.rate ?? 1; const rate = row?.rate ?? 1;
const amountInBase = +(item.amount * rate).toFixed(2); const amountInBase = +(item.amount * rate).toFixed(2);
const createdAt =
item.createdAt ??
new Date(`${item.transactionDate}T00:00:00Z`).toISOString();
const status = item.status ?? 'approved';
const statusUpdatedAt =
item.statusUpdatedAt ??
new Date(
`${item.transactionDate}T00:00:00Z`,
).toISOString();
const approvedAt =
item.approvedAt ??
(status === 'approved' || status === 'paid' ? statusUpdatedAt : null);
insert.run({ insert.run({
...item, ...item,
exchangeRateToBase: rate, exchangeRateToBase: rate,
amountInBase, amountInBase,
project: item.project ?? null, project: item.project ?? null,
memo: item.memo ?? null, memo: item.memo ?? null,
createdAt: createdAt,
item.createdAt ?? status,
new Date(`${item.transactionDate}T00:00:00Z`).toISOString(), statusUpdatedAt,
reimbursementBatch: item.reimbursementBatch ?? null,
reviewNotes: item.reviewNotes ?? null,
submittedBy: item.submittedBy ?? null,
approvedBy:
status === 'approved' || status === 'paid'
? item.approvedBy ?? null
: null,
approvedAt,
isDeleted: item.isDeleted ? 1 : 0,
}); });
} }
}); });

View File

@@ -0,0 +1,117 @@
import { existsSync } from 'node:fs';
import db from './sqlite';
interface MediaRow {
id: number;
chat_id: number;
message_id: number;
user_id: number;
username: null | string;
display_name: null | string;
file_type: string;
file_id: string;
file_unique_id: null | string;
caption: null | string;
file_name: null | string;
file_path: string;
file_size: null | number;
mime_type: null | string;
duration: null | number;
width: null | number;
height: null | number;
forwarded_to: null | number;
created_at: string;
updated_at: string;
}
export interface MediaMessage {
id: number;
chatId: number;
messageId: number;
userId: number;
username?: string;
displayName?: string;
fileType: string;
fileId: string;
fileUniqueId?: string;
caption?: string;
fileName?: string;
filePath: string;
fileSize?: number;
mimeType?: string;
duration?: number;
width?: number;
height?: number;
forwardedTo?: number;
createdAt: string;
updatedAt: string;
available: boolean;
downloadUrl: string | null;
}
function mapMediaRow(row: MediaRow): MediaMessage {
const fileExists = existsSync(row.file_path);
return {
id: row.id,
chatId: row.chat_id,
messageId: row.message_id,
userId: row.user_id,
username: row.username ?? undefined,
displayName: row.display_name ?? undefined,
fileType: row.file_type,
fileId: row.file_id,
fileUniqueId: row.file_unique_id ?? undefined,
caption: row.caption ?? undefined,
fileName: row.file_name ?? undefined,
filePath: row.file_path,
fileSize: row.file_size ?? undefined,
mimeType: row.mime_type ?? undefined,
duration: row.duration ?? undefined,
width: row.width ?? undefined,
height: row.height ?? undefined,
forwardedTo: row.forwarded_to ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
available: fileExists,
downloadUrl: fileExists ? `/finance/media/${row.id}/download` : null,
};
}
export function fetchMediaMessages(params: {
limit?: number;
fileTypes?: string[];
} = {}) {
const clauses: string[] = [];
const bindParams: Record<string, unknown> = {};
if (params.fileTypes && params.fileTypes.length > 0) {
clauses.push(
`file_type IN (${params.fileTypes.map((_, index) => `@type${index}`).join(', ')})`,
);
params.fileTypes.forEach((type, index) => {
bindParams[`type${index}`] = type;
});
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const limitClause =
params.limit && params.limit > 0 ? `LIMIT ${Number(params.limit)}` : '';
const stmt = db.prepare<MediaRow>(
`SELECT id, chat_id, message_id, user_id, username, display_name, file_type, file_id, file_unique_id, caption, file_name, file_path, file_size, mime_type, duration, width, height, forwarded_to, created_at, updated_at FROM finance_media_messages ${where} ORDER BY datetime(created_at) DESC, id DESC ${limitClause}`,
);
return stmt.all(bindParams).map(mapMediaRow);
}
export function getMediaMessageById(id: number) {
const stmt = db.prepare<MediaRow>(
`SELECT id, chat_id, message_id, user_id, username, display_name, file_type, file_id, file_unique_id, caption, file_name, file_path, file_size, mime_type, duration, width, height, forwarded_to, created_at, updated_at FROM finance_media_messages WHERE id = ?`,
);
const row = stmt.get(id);
return row ? mapMediaRow(row) : null;
}

View File

@@ -0,0 +1,15 @@
import { mkdirSync } from 'node:fs';
import { join } from 'pathe';
const MEDIA_ROOT = join(process.cwd(), 'storage', 'telegram-media');
mkdirSync(MEDIA_ROOT, { recursive: true });
export function getMediaRoot() {
return MEDIA_ROOT;
}
export function resolveMediaAbsolutePath(relativePath: string) {
return join(MEDIA_ROOT, relativePath);
}

View File

@@ -693,11 +693,11 @@ export interface Category {
} }
export const MOCK_CATEGORIES: Category[] = [ export const MOCK_CATEGORIES: Category[] = [
// 支出分类 // 支出分类 (ID 1-17)
{ {
id: 1, id: 1,
userId: null, userId: null,
name: '餐饮', name: '餐饮美食',
type: 'expense', type: 'expense',
icon: '🍜', icon: '🍜',
color: '#ff6b6b', color: '#ff6b6b',
@@ -708,9 +708,9 @@ export const MOCK_CATEGORIES: Category[] = [
{ {
id: 2, id: 2,
userId: null, userId: null,
name: '交通', name: '佣金/返佣',
type: 'expense', type: 'expense',
icon: '🚗', icon: '💸',
color: '#4ecdc4', color: '#4ecdc4',
sortOrder: 2, sortOrder: 2,
isSystem: true, isSystem: true,
@@ -719,9 +719,9 @@ export const MOCK_CATEGORIES: Category[] = [
{ {
id: 3, id: 3,
userId: null, userId: null,
name: '购物', name: '分红',
type: 'expense', type: 'expense',
icon: '🛍️', icon: '💰',
color: '#95e1d3', color: '#95e1d3',
sortOrder: 3, sortOrder: 3,
isSystem: true, isSystem: true,
@@ -730,9 +730,9 @@ export const MOCK_CATEGORIES: Category[] = [
{ {
id: 4, id: 4,
userId: null, userId: null,
name: '娱乐', name: '技术/软件',
type: 'expense', type: 'expense',
icon: '🎮', icon: '💻',
color: '#f38181', color: '#f38181',
sortOrder: 4, sortOrder: 4,
isSystem: true, isSystem: true,
@@ -741,9 +741,9 @@ export const MOCK_CATEGORIES: Category[] = [
{ {
id: 5, id: 5,
userId: null, userId: null,
name: '软件订阅', name: '固定资产',
type: 'expense', type: 'expense',
icon: '💻', icon: '🏠',
color: '#aa96da', color: '#aa96da',
sortOrder: 5, sortOrder: 5,
isSystem: true, isSystem: true,
@@ -752,9 +752,9 @@ export const MOCK_CATEGORIES: Category[] = [
{ {
id: 6, id: 6,
userId: null, userId: null,
name: '投资支出', name: '退款',
type: 'expense', type: 'expense',
icon: '📊', icon: '↩️',
color: '#fcbad3', color: '#fcbad3',
sortOrder: 6, sortOrder: 6,
isSystem: true, isSystem: true,
@@ -763,9 +763,9 @@ export const MOCK_CATEGORIES: Category[] = [
{ {
id: 7, id: 7,
userId: null, userId: null,
name: '医疗健康', name: '服务器/技术',
type: 'expense', type: 'expense',
icon: '🏥', icon: '🖥️',
color: '#a8d8ea', color: '#a8d8ea',
sortOrder: 7, sortOrder: 7,
isSystem: true, isSystem: true,
@@ -774,9 +774,9 @@ export const MOCK_CATEGORIES: Category[] = [
{ {
id: 8, id: 8,
userId: null, userId: null,
name: '房租房贷', name: '工资',
type: 'expense', type: 'expense',
icon: '🏠', icon: '💼',
color: '#ffcccc', color: '#ffcccc',
sortOrder: 8, sortOrder: 8,
isSystem: true, isSystem: true,
@@ -785,9 +785,9 @@ export const MOCK_CATEGORIES: Category[] = [
{ {
id: 9, id: 9,
userId: null, userId: null,
name: '教育', name: '借款/转账',
type: 'expense', type: 'expense',
icon: '📚', icon: '🔄',
color: '#ffd3b6', color: '#ffd3b6',
sortOrder: 9, sortOrder: 9,
isSystem: true, isSystem: true,
@@ -796,29 +796,106 @@ export const MOCK_CATEGORIES: Category[] = [
{ {
id: 10, id: 10,
userId: null, userId: null,
name: '广告推广',
type: 'expense',
icon: '📢',
color: '#dfe4ea',
sortOrder: 10,
isSystem: true,
isActive: true,
},
{
id: 11,
userId: null,
name: '交通出行',
type: 'expense',
icon: '🚗',
color: '#74b9ff',
sortOrder: 11,
isSystem: true,
isActive: true,
},
{
id: 12,
userId: null,
name: '购物消费',
type: 'expense',
icon: '🛍️',
color: '#fd79a8',
sortOrder: 12,
isSystem: true,
isActive: true,
},
{
id: 13,
userId: null,
name: '娱乐休闲',
type: 'expense',
icon: '🎮',
color: '#fdcb6e',
sortOrder: 13,
isSystem: true,
isActive: true,
},
{
id: 14,
userId: null,
name: '医疗健康',
type: 'expense',
icon: '🏥',
color: '#55efc4',
sortOrder: 14,
isSystem: true,
isActive: true,
},
{
id: 15,
userId: null,
name: '教育学习',
type: 'expense',
icon: '📚',
color: '#a29bfe',
sortOrder: 15,
isSystem: true,
isActive: true,
},
{
id: 16,
userId: null,
name: '房租房贷',
type: 'expense',
icon: '🏘️',
color: '#ff7675',
sortOrder: 16,
isSystem: true,
isActive: true,
},
{
id: 17,
userId: null,
name: '其他支出', name: '其他支出',
type: 'expense', type: 'expense',
icon: '📝', icon: '📝',
color: '#dfe4ea', color: '#b2bec3',
sortOrder: 99, sortOrder: 99,
isSystem: true, isSystem: true,
isActive: true, isActive: true,
}, },
// 收入分类 // 收入分类 (ID 18-23)
{ {
id: 11, id: 18,
userId: null, userId: null,
name: '工资', name: '工资收入',
type: 'income', type: 'income',
icon: '💼', icon: '💵',
color: '#38ada9', color: '#38ada9',
sortOrder: 1, sortOrder: 1,
isSystem: true, isSystem: true,
isActive: true, isActive: true,
}, },
{ {
id: 12, id: 19,
userId: null, userId: null,
name: '奖金', name: '奖金',
type: 'income', type: 'income',
@@ -829,7 +906,7 @@ export const MOCK_CATEGORIES: Category[] = [
isActive: true, isActive: true,
}, },
{ {
id: 13, id: 20,
userId: null, userId: null,
name: '投资收益', name: '投资收益',
type: 'income', type: 'income',
@@ -840,7 +917,7 @@ export const MOCK_CATEGORIES: Category[] = [
isActive: true, isActive: true,
}, },
{ {
id: 14, id: 21,
userId: null, userId: null,
name: '副业收入', name: '副业收入',
type: 'income', type: 'income',
@@ -851,12 +928,23 @@ export const MOCK_CATEGORIES: Category[] = [
isActive: true, isActive: true,
}, },
{ {
id: 15, id: 22,
userId: null,
name: '退款收入',
type: 'income',
icon: '↩️',
color: '#82ccdd',
sortOrder: 5,
isSystem: true,
isActive: true,
},
{
id: 23,
userId: null, userId: null,
name: '其他收入', name: '其他收入',
type: 'income', type: 'income',
icon: '💰', icon: '💰',
color: '#82ccdd', color: '#10ac84',
sortOrder: 99, sortOrder: 99,
isSystem: true, isSystem: true,
isActive: true, isActive: true,

View File

@@ -9,6 +9,24 @@ mkdirSync(dirname(dbFile), { recursive: true });
const database = new Database(dbFile); const database = new Database(dbFile);
function assertIdentifier(name: string) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new Error(`Invalid identifier: ${name}`);
}
return name;
}
function ensureColumn(table: string, column: string, definition: string) {
const safeTable = assertIdentifier(table);
const safeColumn = assertIdentifier(column);
const columns = database
.prepare<{ name: string }>(`PRAGMA table_info(${safeTable})`)
.all();
if (!columns.some((item) => item.name === safeColumn)) {
database.exec(`ALTER TABLE ${safeTable} ADD COLUMN ${definition}`);
}
}
database.pragma('journal_mode = WAL'); database.pragma('journal_mode = WAL');
database.exec(` database.exec(`
@@ -72,6 +90,13 @@ database.exec(`
project TEXT, project TEXT,
memo TEXT, memo TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'approved',
status_updated_at TEXT,
reimbursement_batch TEXT,
review_notes TEXT,
submitted_by TEXT,
approved_by TEXT,
approved_at TEXT,
is_deleted INTEGER NOT NULL DEFAULT 0, is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT, deleted_at TEXT,
FOREIGN KEY (currency) REFERENCES finance_currencies(code), FOREIGN KEY (currency) REFERENCES finance_currencies(code),
@@ -80,4 +105,56 @@ database.exec(`
); );
`); `);
ensureColumn(
'finance_transactions',
'status',
"status TEXT NOT NULL DEFAULT 'approved'",
);
ensureColumn('finance_transactions', 'status_updated_at', 'status_updated_at TEXT');
ensureColumn(
'finance_transactions',
'reimbursement_batch',
'reimbursement_batch TEXT',
);
ensureColumn('finance_transactions', 'review_notes', 'review_notes TEXT');
ensureColumn('finance_transactions', 'submitted_by', 'submitted_by TEXT');
ensureColumn('finance_transactions', 'approved_by', 'approved_by TEXT');
ensureColumn('finance_transactions', 'approved_at', 'approved_at TEXT');
database.exec(`
CREATE TABLE IF NOT EXISTS finance_media_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
username TEXT,
display_name TEXT,
file_type TEXT NOT NULL,
file_id TEXT NOT NULL,
file_unique_id TEXT,
caption TEXT,
file_name TEXT,
file_path TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
duration INTEGER,
width INTEGER,
height INTEGER,
forwarded_to INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(chat_id, message_id)
);
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_finance_media_messages_created_at
ON finance_media_messages (created_at DESC);
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_finance_media_messages_user_id
ON finance_media_messages (user_id);
`);
export default database; export default database;

View File

@@ -0,0 +1,73 @@
const DEFAULT_WEBHOOK_URL =
process.env.TELEGRAM_WEBHOOK_URL ??
process.env.FINANCE_BOT_WEBHOOK_URL ??
'http://192.168.9.28:8889/webhook/transaction';
const DEFAULT_WEBHOOK_SECRET =
process.env.TELEGRAM_WEBHOOK_SECRET ??
process.env.FINANCE_BOT_WEBHOOK_SECRET ??
'ktapp.cc';
interface TransactionPayload {
[key: string]: unknown;
}
async function postToWebhook(
payload: TransactionPayload,
webhookURL: string,
webhookSecret: string,
) {
try {
const response = await fetch(webhookURL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${webhookSecret}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
console.error(
'[telegram-webhook] Failed to notify webhook',
response.status,
text,
);
}
} catch (error) {
console.error('[telegram-webhook] Webhook request error', error);
}
}
export async function notifyTransactionWebhook(
transaction: TransactionPayload,
options: {
webhookURL?: string;
webhookSecret?: string;
action?: string;
source?: string;
} = {},
) {
const url = (options.webhookURL ?? DEFAULT_WEBHOOK_URL).trim();
const secret = (options.webhookSecret ?? DEFAULT_WEBHOOK_SECRET).trim();
if (!url || !secret) {
return;
}
const payload: TransactionPayload = {
...transaction,
};
if (options.action) {
payload.action = options.action;
}
if (options.source) {
payload.source = options.source;
} else {
payload.source = payload.source ?? 'finwise-backend';
}
await postToWebhook(payload, url, secret);
}

View File

@@ -0,0 +1,56 @@
# Finwise Finance MCP Service
该包将 Finwise Pro 的 `/api/finance/*` 接口封装为 Model Context Protocol (MCP) 工具,方便 Codex、Claude 等 MCP 客户端直接调用财务能力。
## 使用步骤
1. 安装依赖
```bash
pnpm install
```
2. 构建服务
```bash
(本服务为纯 Node.js 实现,如无额外需求可跳过构建)
```
3. 启动服务(示例)
```bash
FINANCE_BASIC_USERNAME=atai \
FINANCE_BASIC_PASSWORD=wengewudi666808 \
node apps/finance-mcp-service/src/index.js
```
可选环境变量:
| 变量 | 含义 |
| --- | --- |
| `FINANCE_API_BASE_URL` | 默认 `http://172.16.74.149:5666`,如需变更可重设。 |
| `FINANCE_API_KEY` | 将作为 Bearer Token 附加在请求头。 |
| `FINANCE_API_TIMEOUT` | 请求超时(毫秒)。 |
| `FINANCE_BASIC_USERNAME` / `FINANCE_BASIC_PASSWORD` | 使用 HTTP Basic Auth 访问后端。 |
如需在 Codex 中自动启动该 MCP 服务,可在 `config.json` 中加入以下配置片段(路径默认位于 `~/.config/codex/config.json`
```json
{
"mcpServers": {
"finwise-finance": {
"command": "node",
"args": ["apps/finance-mcp-service/src/index.js"],
"env": {
"FINANCE_BASIC_USERNAME": "atai",
"FINANCE_BASIC_PASSWORD": "wengewudi666808"
},
"cwd": "/Users/fuwuqi/Projects/web-apps/finwise-pro"
}
}
}
```
配置完成后,重启 Codex 即可在 MCP 面板中看到 `finwise-finance`,并通过工具调用各类财务接口。
工具清单与入参定义详见 `src/index.ts`。

View File

@@ -0,0 +1,12 @@
{
"name": "@vben/finance-mcp-service",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "MCP service exposing Finwise Pro finance APIs",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {},
"devDependencies": {}
}

9
apps/finance-mcp-service/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}

View File

@@ -0,0 +1,285 @@
import { Buffer } from 'node:buffer';
export class FinanceClient {
constructor(config) {
if (!config?.baseUrl) {
throw new Error('FinanceClient requires a baseUrl');
}
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.apiKey = config.apiKey;
this.basicAuth = validateBasicAuth(config.basicAuth);
this.timeoutMs = config.timeoutMs;
}
async listAccounts(params = {}) {
return this.get('/api/finance/accounts', params);
}
async listBudgets() {
return this.get('/api/finance/budgets');
}
async createBudget(payload) {
return this.post('/api/finance/budgets', payload);
}
async updateBudget(id, payload) {
return this.put(`/api/finance/budgets/${id}`, payload);
}
async deleteBudget(id) {
return this.delete(`/api/finance/budgets/${id}`);
}
async listCategories(params = {}) {
return this.get('/api/finance/categories', params);
}
async createCategory(payload) {
return this.post('/api/finance/categories', payload);
}
async updateCategory(id, payload) {
return this.put(`/api/finance/categories/${id}`, payload);
}
async deleteCategory(id) {
return this.delete(`/api/finance/categories/${id}`);
}
async listCurrencies() {
return this.get('/api/finance/currencies');
}
async listExchangeRates(params = {}) {
const query = {};
if (params.fromCurrency) query.from = params.fromCurrency;
if (params.toCurrency) query.to = params.toCurrency;
if (params.date) query.date = params.date;
return this.get('/api/finance/exchange-rates', query);
}
async listTransactions(params = {}) {
const query = {};
if (params.type) query.type = params.type;
if (params.statuses?.length) {
query.statuses = params.statuses.join(',');
}
if (params.includeDeleted !== undefined) {
query.includeDeleted = params.includeDeleted;
}
return this.get('/api/finance/transactions', query);
}
async createTransaction(payload) {
return this.post('/api/finance/transactions', payload);
}
async updateTransaction(id, payload) {
return this.put(`/api/finance/transactions/${id}`, payload);
}
async deleteTransaction(id) {
return this.delete(`/api/finance/transactions/${id}`);
}
async listReimbursements(params = {}) {
const query = {};
if (params.type) query.type = params.type;
if (params.statuses?.length) {
query.statuses = params.statuses.join(',');
}
if (params.includeDeleted !== undefined) {
query.includeDeleted = params.includeDeleted;
}
return this.get('/api/finance/reimbursements', query);
}
async createReimbursement(payload) {
return this.post('/api/finance/reimbursements', payload);
}
async updateReimbursement(id, payload) {
return this.put(`/api/finance/reimbursements/${id}`, payload);
}
async listMedia(params = {}) {
const query = {};
if (params.limit !== undefined) query.limit = params.limit;
if (params.fileTypes?.length) query.types = params.fileTypes.join(',');
return this.get('/api/finance/media', query);
}
async getMediaById(id) {
return this.get(`/api/finance/media/${id}`);
}
async downloadMedia(id) {
const url = this.createUrl(`/api/finance/media/${id}/download`);
const response = await this.performFetch(url, {
method: 'GET',
headers: this.buildHeaders(false),
});
if (!response.ok) {
const payload = await this.safeParseEnvelope(response);
if (payload) {
throw new Error(payload.message || 'Failed to download media file');
}
throw new Error(`Failed to download media file (HTTP ${response.status})`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return {
fileName: this.extractFileName(response.headers.get('content-disposition')),
mimeType: response.headers.get('content-type') ?? 'application/octet-stream',
size: buffer.byteLength,
base64: buffer.toString('base64'),
};
}
async get(path, query) {
return this.request('GET', path, { query });
}
async post(path, body) {
return this.request('POST', path, { body });
}
async put(path, body) {
return this.request('PUT', path, { body });
}
async delete(path) {
return this.request('DELETE', path);
}
async request(method, path, options = {}) {
const url = this.createUrl(path);
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
if (value.length > 0) url.searchParams.set(key, value.join(','));
} else if (typeof value === 'boolean') {
url.searchParams.set(key, value ? 'true' : 'false');
} else {
url.searchParams.set(key, String(value));
}
}
}
const response = await this.performFetch(url, {
method,
headers: this.buildHeaders(method !== 'GET' && method !== 'DELETE'),
body: options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await this.parseEnvelope(response, path);
if (payload.code !== 0) {
throw new Error(payload.message || 'Finance API returned an error');
}
return payload.data;
}
createUrl(path) {
if (!path.startsWith('/')) {
path = `/${path}`;
}
return new URL(path, this.baseUrl);
}
buildHeaders(json) {
const headers = { Accept: 'application/json' };
if (json) headers['Content-Type'] = 'application/json';
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
else if (this.basicAuth) headers.Authorization = `Basic ${createBasicToken(this.basicAuth)}`;
return headers;
}
async performFetch(url, init) {
const controller = this.timeoutMs ? new AbortController() : undefined;
let timer;
if (controller) {
init.signal = controller.signal;
timer = setTimeout(() => controller.abort(), this.timeoutMs);
}
try {
return await fetch(url, init);
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`Request to ${url.pathname} timed out`);
}
throw error;
} finally {
if (timer) clearTimeout(timer);
}
}
async parseEnvelope(response, path) {
const payload = await this.safeParseEnvelope(response);
if (!payload) {
const text = await response.text();
throw new Error(`Unexpected response from ${path}: ${text || response.statusText}`);
}
if (!response.ok) {
throw new Error(payload.message || `Finance API request failed (HTTP ${response.status})`);
}
return payload;
}
async safeParseEnvelope(response) {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) return null;
try {
return await response.clone().json();
} catch {
return null;
}
}
extractFileName(contentDisposition) {
if (!contentDisposition) return undefined;
const filenameStar = contentDisposition.match(/filename\*=([^;]+)/i);
if (filenameStar?.[1]) {
const value = filenameStar[1].replace(/^UTF-8''/, '');
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
const filename = contentDisposition.match(/filename="?([^";]+)"?/i);
return filename?.[1];
}
}
const validateBasicAuth = (credentials) => {
if (!credentials) return undefined;
const username = credentials.username ?? credentials.user ?? credentials.login;
const password = credentials.password ?? credentials.pass;
if (!username && !password) return undefined;
if (!username || !password) {
throw new Error('FinanceClient basicAuth requires both username and password');
}
return { username: String(username), password: String(password) };
};
const createBasicToken = ({ username, password }) =>
Buffer.from(`${username}:${password}`, 'utf8').toString('base64');

View File

@@ -0,0 +1,901 @@
import process from 'node:process';
import { FinanceClient } from './finance-client.js';
process.on('exit', (code) => {
process.stderr.write(`[finwise-finance] process exit with code ${code}\n`);
});
process.on('uncaughtException', (error) => {
process.stderr.write(`[finwise-finance] uncaughtException: ${error.stack ?? error.message}\n`);
});
process.on('unhandledRejection', (reason) => {
process.stderr.write(`[finwise-finance] unhandledRejection: ${reason}\n`);
});
class McpServer {
constructor(options) {
this.options = options;
this.tools = new Map();
this.metadata = [];
this.buffer = '';
this.expectedLength = null;
this.initialized = false;
for (const tool of options.tools) {
if (this.tools.has(tool.name)) {
throw new Error(`Duplicate MCP tool name: ${tool.name}`);
}
this.tools.set(tool.name, tool);
this.metadata.push({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
});
}
}
start() {
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
this.buffer += chunk;
void this.drain();
});
process.stdin.on('end', () => {
this.log('stdin ended');
});
process.stdin.on('close', () => {
this.log('stdin closed');
});
process.stdin.resume();
this.log('MCP service ready');
}
write(payload) {
const json = JSON.stringify(payload);
const frame = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
process.stdout.write(frame);
}
respond(id, result) {
this.log(`responding to ${id} with result`);
if (id === undefined) return;
this.write({ jsonrpc: '2.0', id, result });
}
respondError(id, code, message) {
this.log(`responding error to ${id}: [${code}] ${message}`);
if (id === undefined) return;
this.write({ jsonrpc: '2.0', id, error: { code, message } });
}
notify(method, params) {
this.log(`notifying ${method}`);
this.write({ jsonrpc: '2.0', method, params });
}
async drain() {
while (true) {
if (this.expectedLength === null) {
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) return;
const header = this.buffer.slice(0, headerEnd);
const match = header.match(/content-length:\s*(\d+)/i);
if (!match) {
this.buffer = this.buffer.slice(headerEnd + 4);
continue;
}
this.expectedLength = Number.parseInt(match[1], 10);
this.buffer = this.buffer.slice(headerEnd + 4);
}
if (this.buffer.length < (this.expectedLength ?? 0)) return;
const body = this.buffer.slice(0, this.expectedLength ?? 0);
this.buffer = this.buffer.slice(this.expectedLength ?? 0);
this.expectedLength = null;
await this.handleMessage(body);
}
}
async handleMessage(payload) {
this.log(`received payload: ${payload}`);
let request;
try {
request = JSON.parse(payload);
} catch {
this.respondError(null, -32700, 'Parse error');
return;
}
if (!request || request.jsonrpc !== '2.0' || typeof request.method !== 'string') {
this.respondError(request?.id, -32600, 'Invalid Request');
return;
}
try {
await this.dispatch(request);
} catch (error) {
this.log(`Unexpected error: ${error.message}`);
this.respondError(request.id, -32000, error.message);
}
}
async dispatch(request) {
switch (request.method) {
case 'initialize': {
if (this.initialized) {
this.respondError(request.id, -32600, 'Already initialized');
return;
}
this.initialized = true;
this.respond(request.id, {
protocolVersion: '2024-10-07',
capabilities: { tools: { list: true, call: true } },
service: {
name: this.options.name,
version: this.options.version,
description: this.options.description,
},
});
this.notify('notifications/ready', {});
return;
}
case 'tools/list': {
this.assertInitialized('tools/list');
this.respond(request.id, { tools: this.metadata });
return;
}
case 'tools/call': {
this.assertInitialized('tools/call');
const params = request.params ?? {};
const toolName = params.name;
if (!toolName || typeof toolName !== 'string') {
this.respondError(request.id, -32602, 'Tool name is required');
return;
}
const tool = this.tools.get(toolName);
if (!tool) {
this.respondError(request.id, -32601, `Unknown tool: ${toolName}`);
return;
}
try {
const result = await tool.handler(params.arguments ?? {});
this.respond(request.id, result);
} catch (error) {
this.respondError(request.id, -32001, error.message);
}
return;
}
case 'ping': {
this.respond(request.id, 'pong');
return;
}
case 'shutdown': {
this.respond(request.id, null);
process.nextTick(() => process.exit(0));
return;
}
default: {
this.respondError(request.id, -32601, `Method not found: ${request.method}`);
}
}
}
assertInitialized(method) {
if (!this.initialized) {
throw new Error(`Received ${method} before initialize`);
}
}
log(message) {
process.stderr.write(`[${this.options.name}] ${message}\n`);
}
}
const jsonResult = (data) => ({
content: [
{
type: 'application/json',
data,
},
],
});
const ensureNumber = (value, field) => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (!Number.isNaN(parsed)) return parsed;
}
throw new Error(`${field} must be a number`);
};
const optionalNumber = (value, field) => {
if (value === undefined || value === null) return undefined;
return ensureNumber(value, field);
};
const optionalNullableNumber = (value, field) => {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === 'null') return null;
}
return ensureNumber(value, field);
};
const ensureString = (value, field) => {
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) throw new Error(`${field} cannot be empty`);
return trimmed;
}
if (value === undefined || value === null) throw new Error(`${field} is required`);
return ensureString(String(value), field);
};
const optionalString = (value) => {
if (value === undefined || value === null) return undefined;
return String(value);
};
const optionalNullableString = (value) => {
if (value === undefined) return undefined;
if (value === null) return null;
const normalized = String(value).trim();
if (normalized.toLowerCase() === 'null') return null;
return normalized;
};
const optionalBoolean = (value, field) => {
if (value === undefined || value === null) return undefined;
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['true', '1', 'yes', 'y'].includes(normalized)) return true;
if (['false', '0', 'no', 'n'].includes(normalized)) return false;
}
throw new Error(`${field} must be boolean`);
};
const parseStringArray = (value) => {
if (value === undefined || value === null) return undefined;
let items = [];
if (Array.isArray(value)) {
items = value.map((item) => String(item).trim()).filter(Boolean);
} else if (typeof value === 'string') {
items = value.split(',').map((item) => item.trim()).filter(Boolean);
} else {
items = [String(value).trim()].filter(Boolean);
}
return items.length ? items : undefined;
};
const buildTransactionCreatePayload = (args, options = {}) => {
const payload = {
type: optionalString(args?.type) ?? options.defaultType ?? ensureString(args?.type, 'type'),
amount: ensureNumber(args?.amount, 'amount'),
currency: optionalString(args?.currency) ?? 'CNY',
transactionDate: ensureString(args?.transactionDate, 'transactionDate'),
};
const categoryId = optionalNullableNumber(args?.categoryId, 'categoryId');
if (categoryId !== undefined) payload.categoryId = categoryId;
const accountId = optionalNullableNumber(args?.accountId, 'accountId');
if (accountId !== undefined) payload.accountId = accountId;
const description = optionalString(args?.description);
if (description !== undefined) payload.description = description;
const project = optionalNullableString(args?.project);
if (project !== undefined) payload.project = project;
const memo = optionalNullableString(args?.memo);
if (memo !== undefined) payload.memo = memo;
const status = optionalString(args?.status);
if (status !== undefined) payload.status = status;
const reimbursementBatch = optionalNullableString(args?.reimbursementBatch);
if (reimbursementBatch !== undefined) payload.reimbursementBatch = reimbursementBatch;
const reviewNotes = optionalNullableString(args?.reviewNotes);
if (reviewNotes !== undefined) payload.reviewNotes = reviewNotes;
const submittedBy = optionalNullableString(args?.submittedBy);
if (submittedBy !== undefined) payload.submittedBy = submittedBy;
const approvedBy = optionalNullableString(args?.approvedBy);
if (approvedBy !== undefined) payload.approvedBy = approvedBy;
const approvedAt = optionalNullableString(args?.approvedAt);
if (approvedAt !== undefined) payload.approvedAt = approvedAt;
const statusUpdatedAt = optionalNullableString(args?.statusUpdatedAt);
if (statusUpdatedAt !== undefined) payload.statusUpdatedAt = statusUpdatedAt;
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
return payload;
};
const buildTransactionUpdatePayload = (args) => {
const payload = {};
if (args?.type !== undefined) payload.type = ensureString(args.type, 'type');
if (args?.amount !== undefined) payload.amount = ensureNumber(args.amount, 'amount');
if (args?.currency !== undefined) payload.currency = ensureString(args.currency, 'currency');
if (args?.transactionDate !== undefined) payload.transactionDate = ensureString(args.transactionDate, 'transactionDate');
if (args?.categoryId !== undefined) payload.categoryId = optionalNullableNumber(args.categoryId, 'categoryId');
if (args?.accountId !== undefined) payload.accountId = optionalNullableNumber(args.accountId, 'accountId');
if (args?.description !== undefined) payload.description = args.description === null ? '' : String(args.description);
if (args?.project !== undefined) payload.project = optionalNullableString(args.project) ?? null;
if (args?.memo !== undefined) payload.memo = optionalNullableString(args.memo) ?? null;
if (args?.status !== undefined) payload.status = ensureString(args.status, 'status');
if (args?.statusUpdatedAt !== undefined) payload.statusUpdatedAt = ensureString(args.statusUpdatedAt, 'statusUpdatedAt');
if (args?.reimbursementBatch !== undefined) payload.reimbursementBatch = optionalNullableString(args.reimbursementBatch) ?? null;
if (args?.reviewNotes !== undefined) payload.reviewNotes = optionalNullableString(args.reviewNotes) ?? null;
if (args?.submittedBy !== undefined) payload.submittedBy = optionalNullableString(args.submittedBy) ?? null;
if (args?.approvedBy !== undefined) payload.approvedBy = optionalNullableString(args.approvedBy) ?? null;
if (args?.approvedAt !== undefined) payload.approvedAt = optionalNullableString(args.approvedAt) ?? null;
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
return payload;
};
const createFinanceTools = (client) => {
const tools = [];
tools.push({
name: 'finance_list_accounts',
description: '列出账户,可选货币过滤',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
currency: { type: 'string', description: 'ISO 4217 货币代码' },
},
},
handler: async (args) => {
const currency = optionalString(args?.currency);
return jsonResult(await client.listAccounts(currency ? { currency } : {}));
},
});
tools.push({
name: 'finance_list_budgets',
description: '查询预算列表',
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
handler: async () => jsonResult(await client.listBudgets()),
});
tools.push({
name: 'finance_create_budget',
description: '创建预算',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['category', 'categoryId', 'limit', 'currency', 'period'],
properties: {
category: { type: 'string' },
categoryId: { type: 'number' },
emoji: { type: 'string' },
limit: { type: 'number' },
spent: { type: 'number' },
remaining: { type: 'number' },
percentage: { type: 'number' },
currency: { type: 'string' },
period: { type: 'string' },
alertThreshold: { type: 'number' },
description: { type: 'string' },
autoRenew: { type: 'boolean' },
overspendAlert: { type: 'boolean' },
dailyReminder: { type: 'boolean' },
monthlyTrend: { type: 'number' },
isDeleted: { type: 'boolean' },
},
},
handler: async (args) => {
const payload = {
category: ensureString(args?.category, 'category'),
categoryId: ensureNumber(args?.categoryId, 'categoryId'),
emoji: optionalString(args?.emoji),
limit: ensureNumber(args?.limit, 'limit'),
spent: optionalNumber(args?.spent, 'spent'),
remaining: optionalNumber(args?.remaining, 'remaining'),
percentage: optionalNumber(args?.percentage, 'percentage'),
currency: ensureString(args?.currency, 'currency'),
period: ensureString(args?.period, 'period'),
alertThreshold: optionalNumber(args?.alertThreshold, 'alertThreshold'),
description: optionalString(args?.description),
autoRenew: optionalBoolean(args?.autoRenew, 'autoRenew'),
overspendAlert: optionalBoolean(args?.overspendAlert, 'overspendAlert'),
dailyReminder: optionalBoolean(args?.dailyReminder, 'dailyReminder'),
monthlyTrend: optionalNumber(args?.monthlyTrend, 'monthlyTrend'),
isDeleted: optionalBoolean(args?.isDeleted, 'isDeleted'),
};
return jsonResult(await client.createBudget(payload));
},
});
tools.push({
name: 'finance_update_budget',
description: '更新预算',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: {
id: { type: 'number' },
category: { type: 'string' },
categoryId: { type: 'number' },
emoji: { type: 'string' },
limit: { type: 'number' },
spent: { type: 'number' },
remaining: { type: 'number' },
percentage: { type: 'number' },
currency: { type: 'string' },
period: { type: 'string' },
alertThreshold: { type: 'number' },
description: { type: 'string' },
autoRenew: { type: 'boolean' },
overspendAlert: { type: 'boolean' },
dailyReminder: { type: 'boolean' },
monthlyTrend: { type: 'number' },
isDeleted: { type: 'boolean' },
},
},
handler: async (args) => {
const id = ensureNumber(args?.id, 'id');
const payload = {};
if (args?.category !== undefined) payload.category = ensureString(args.category, 'category');
if (args?.categoryId !== undefined) payload.categoryId = ensureNumber(args.categoryId, 'categoryId');
if (args?.emoji !== undefined) payload.emoji = optionalString(args.emoji);
if (args?.limit !== undefined) payload.limit = ensureNumber(args.limit, 'limit');
if (args?.spent !== undefined) payload.spent = ensureNumber(args.spent, 'spent');
if (args?.remaining !== undefined) payload.remaining = ensureNumber(args.remaining, 'remaining');
if (args?.percentage !== undefined) payload.percentage = ensureNumber(args.percentage, 'percentage');
if (args?.currency !== undefined) payload.currency = ensureString(args.currency, 'currency');
if (args?.period !== undefined) payload.period = ensureString(args.period, 'period');
if (args?.alertThreshold !== undefined) payload.alertThreshold = ensureNumber(args.alertThreshold, 'alertThreshold');
if (args?.description !== undefined) payload.description = optionalString(args.description);
const autoRenew = optionalBoolean(args?.autoRenew, 'autoRenew');
if (autoRenew !== undefined) payload.autoRenew = autoRenew;
const overspendAlert = optionalBoolean(args?.overspendAlert, 'overspendAlert');
if (overspendAlert !== undefined) payload.overspendAlert = overspendAlert;
const dailyReminder = optionalBoolean(args?.dailyReminder, 'dailyReminder');
if (dailyReminder !== undefined) payload.dailyReminder = dailyReminder;
if (args?.monthlyTrend !== undefined) payload.monthlyTrend = ensureNumber(args.monthlyTrend, 'monthlyTrend');
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
return jsonResult(await client.updateBudget(id, payload));
},
});
tools.push({
name: 'finance_delete_budget',
description: '删除预算(软删)',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: { id: { type: 'number' } },
},
handler: async (args) => jsonResult(await client.deleteBudget(ensureNumber(args?.id, 'id'))),
});
tools.push({
name: 'finance_list_categories',
description: '查询分类,可按类型过滤',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
type: { type: 'string', description: 'expense / income' },
},
},
handler: async (args) => {
const type = optionalString(args?.type);
return jsonResult(await client.listCategories(type ? { type } : {}));
},
});
tools.push({
name: 'finance_create_category',
description: '创建分类',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['name', 'type'],
properties: {
name: { type: 'string' },
type: { type: 'string' },
icon: { type: 'string' },
color: { type: 'string' },
},
},
handler: async (args) => jsonResult(
await client.createCategory({
name: ensureString(args?.name, 'name'),
type: ensureString(args?.type, 'type'),
icon: optionalString(args?.icon),
color: optionalString(args?.color),
}),
),
});
tools.push({
name: 'finance_update_category',
description: '更新分类',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: {
id: { type: 'number' },
name: { type: 'string' },
icon: { type: 'string' },
color: { type: 'string' },
isActive: { type: 'boolean' },
},
},
handler: async (args) => {
const id = ensureNumber(args?.id, 'id');
const payload = {};
if (args?.name !== undefined) payload.name = ensureString(args.name, 'name');
if (args?.icon !== undefined) payload.icon = optionalString(args.icon);
if (args?.color !== undefined) payload.color = optionalString(args.color);
const isActive = optionalBoolean(args?.isActive, 'isActive');
if (isActive !== undefined) payload.isActive = isActive;
return jsonResult(await client.updateCategory(id, payload));
},
});
tools.push({
name: 'finance_delete_category',
description: '删除分类(软删)',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: { id: { type: 'number' } },
},
handler: async (args) => jsonResult(await client.deleteCategory(ensureNumber(args?.id, 'id'))),
});
tools.push({
name: 'finance_list_currencies',
description: '列出可用货币',
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
handler: async () => jsonResult(await client.listCurrencies()),
});
tools.push({
name: 'finance_list_exchange_rates',
description: '查询汇率',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fromCurrency: { type: 'string' },
toCurrency: { type: 'string' },
date: { type: 'string' },
},
},
handler: async (args) => {
const params = {};
if (args?.fromCurrency !== undefined) params.fromCurrency = ensureString(args.fromCurrency, 'fromCurrency');
if (args?.toCurrency !== undefined) params.toCurrency = ensureString(args.toCurrency, 'toCurrency');
if (args?.date !== undefined) params.date = ensureString(args.date, 'date');
return jsonResult(await client.listExchangeRates(params));
},
});
tools.push({
name: 'finance_list_transactions',
description: '查询交易列表',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
type: { type: 'string' },
statuses: { type: ['array', 'string'], items: { type: 'string' } },
includeDeleted: { type: 'boolean' },
},
},
handler: async (args) => {
const type = optionalString(args?.type);
const statuses = parseStringArray(args?.statuses);
const includeDeleted = optionalBoolean(args?.includeDeleted, 'includeDeleted');
return jsonResult(
await client.listTransactions({
...(type ? { type } : {}),
...(statuses ? { statuses } : {}),
...(includeDeleted !== undefined ? { includeDeleted } : {}),
}),
);
},
});
tools.push({
name: 'finance_create_transaction',
description: '创建交易',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['type', 'amount', 'transactionDate'],
properties: {
type: { type: 'string' },
amount: { type: 'number' },
currency: { type: 'string' },
categoryId: { type: ['number', 'null'] },
accountId: { type: ['number', 'null'] },
transactionDate: { type: 'string' },
description: { type: 'string' },
project: { type: ['string', 'null'] },
memo: { type: ['string', 'null'] },
status: { type: 'string' },
reimbursementBatch: { type: ['string', 'null'] },
reviewNotes: { type: ['string', 'null'] },
submittedBy: { type: ['string', 'null'] },
approvedBy: { type: ['string', 'null'] },
approvedAt: { type: ['string', 'null'] },
statusUpdatedAt: { type: ['string', 'null'] },
isDeleted: { type: 'boolean' },
},
},
handler: async (args) => jsonResult(await client.createTransaction(buildTransactionCreatePayload(args))),
});
tools.push({
name: 'finance_update_transaction',
description: '更新交易',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: {
id: { type: 'number' },
type: { type: 'string' },
amount: { type: 'number' },
currency: { type: 'string' },
categoryId: { type: ['number', 'null'] },
accountId: { type: ['number', 'null'] },
transactionDate: { type: 'string' },
description: { type: ['string', 'null'] },
project: { type: ['string', 'null'] },
memo: { type: ['string', 'null'] },
status: { type: 'string' },
statusUpdatedAt: { type: 'string' },
reimbursementBatch: { type: ['string', 'null'] },
reviewNotes: { type: ['string', 'null'] },
submittedBy: { type: ['string', 'null'] },
approvedBy: { type: ['string', 'null'] },
approvedAt: { type: ['string', 'null'] },
isDeleted: { type: 'boolean' },
},
},
handler: async (args) => jsonResult(
await client.updateTransaction(ensureNumber(args?.id, 'id'), buildTransactionUpdatePayload(args)),
),
});
tools.push({
name: 'finance_delete_transaction',
description: '删除交易(软删)',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: { id: { type: 'number' } },
},
handler: async (args) => jsonResult(await client.deleteTransaction(ensureNumber(args?.id, 'id'))),
});
tools.push({
name: 'finance_list_reimbursements',
description: '查询报销单',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
type: { type: 'string' },
statuses: { type: ['array', 'string'], items: { type: 'string' } },
includeDeleted: { type: 'boolean' },
},
},
handler: async (args) => {
const type = optionalString(args?.type);
const statuses = parseStringArray(args?.statuses);
const includeDeleted = optionalBoolean(args?.includeDeleted, 'includeDeleted');
return jsonResult(
await client.listReimbursements({
...(type ? { type } : {}),
...(statuses ? { statuses } : {}),
...(includeDeleted !== undefined ? { includeDeleted } : {}),
}),
);
},
});
tools.push({
name: 'finance_create_reimbursement',
description: '创建报销单',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['amount', 'transactionDate'],
properties: {
type: { type: 'string' },
amount: { type: 'number' },
currency: { type: 'string' },
categoryId: { type: ['number', 'null'] },
accountId: { type: ['number', 'null'] },
transactionDate: { type: 'string' },
description: { type: 'string' },
project: { type: ['string', 'null'] },
memo: { type: ['string', 'null'] },
status: { type: 'string' },
reimbursementBatch: { type: ['string', 'null'] },
reviewNotes: { type: ['string', 'null'] },
submittedBy: { type: ['string', 'null'] },
approvedBy: { type: ['string', 'null'] },
approvedAt: { type: ['string', 'null'] },
statusUpdatedAt: { type: ['string', 'null'] },
isDeleted: { type: 'boolean' },
},
},
handler: async (args) => jsonResult(
await client.createReimbursement(buildTransactionCreatePayload(args, { defaultType: 'expense' })),
),
});
tools.push({
name: 'finance_update_reimbursement',
description: '更新报销单',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: {
id: { type: 'number' },
type: { type: 'string' },
amount: { type: 'number' },
currency: { type: 'string' },
categoryId: { type: ['number', 'null'] },
accountId: { type: ['number', 'null'] },
transactionDate: { type: 'string' },
description: { type: ['string', 'null'] },
project: { type: ['string', 'null'] },
memo: { type: ['string', 'null'] },
status: { type: 'string' },
statusUpdatedAt: { type: 'string' },
reimbursementBatch: { type: ['string', 'null'] },
reviewNotes: { type: ['string', 'null'] },
submittedBy: { type: ['string', 'null'] },
approvedBy: { type: ['string', 'null'] },
approvedAt: { type: ['string', 'null'] },
isDeleted: { type: 'boolean' },
},
},
handler: async (args) => jsonResult(
await client.updateReimbursement(
ensureNumber(args?.id, 'id'),
buildTransactionUpdatePayload(args),
),
),
});
tools.push({
name: 'finance_list_media',
description: '查询媒体消息',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
limit: { type: 'number' },
fileTypes: { type: ['array', 'string'], items: { type: 'string' } },
},
},
handler: async (args) => {
const limit = optionalNumber(args?.limit, 'limit');
const fileTypes = parseStringArray(args?.fileTypes);
return jsonResult(
await client.listMedia({
...(limit !== undefined ? { limit } : {}),
...(fileTypes ? { fileTypes } : {}),
}),
);
},
});
tools.push({
name: 'finance_get_media',
description: '根据 ID 获取媒体详情',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: { id: { type: 'number' } },
},
handler: async (args) => jsonResult(await client.getMediaById(ensureNumber(args?.id, 'id'))),
});
tools.push({
name: 'finance_download_media',
description: '下载媒体文件并返回 Base64',
inputSchema: {
type: 'object',
additionalProperties: false,
required: ['id'],
properties: {
id: { type: 'number' },
includeMetadata: { type: 'boolean', default: true },
},
},
outputSchema: {
type: 'object',
properties: {
fileName: { type: ['string', 'null'] },
mimeType: { type: 'string' },
size: { type: 'number' },
base64: { type: 'string' },
metadata: { type: ['object', 'null'] },
},
},
handler: async (args) => {
const id = ensureNumber(args?.id, 'id');
const includeMetadata = optionalBoolean(args?.includeMetadata, 'includeMetadata');
const file = await client.downloadMedia(id);
const metadata = includeMetadata === false ? null : await client.getMediaById(id);
return jsonResult({ ...file, metadata });
},
});
return tools;
};
const createServer = () => {
const baseUrl =
process.env.FINANCE_API_BASE_URL ?? 'http://172.16.74.149:5666';
const apiKey = process.env.FINANCE_API_KEY;
const timeoutEnv = process.env.FINANCE_API_TIMEOUT;
const timeout = timeoutEnv ? Number.parseInt(timeoutEnv, 10) : undefined;
const basicUsername =
process.env.FINANCE_BASIC_USERNAME ??
process.env.FINANCE_BASIC_USER ??
process.env.FINANCE_USERNAME;
const basicPassword =
process.env.FINANCE_BASIC_PASSWORD ??
process.env.FINANCE_PASSWORD;
const basicAuth =
basicUsername && basicPassword ? { username: basicUsername, password: basicPassword } : undefined;
const client = new FinanceClient({
baseUrl,
apiKey,
basicAuth,
timeoutMs: Number.isFinite(timeout ?? NaN) ? timeout : undefined,
});
return new McpServer({
name: 'finwise-finance',
version: '0.1.0',
description: 'Finwise Pro 财务接口 MCP 服务',
tools: createFinanceTools(client),
});
};
createServer().start();

View File

@@ -1,7 +1,7 @@
VITE_BASE=/ VITE_BASE=/
# 接口地址 # 接口地址
VITE_GLOB_API_URL=https://mock-napi.vben.pro/api VITE_GLOB_API_URL=http://192.168.9.149:5320/api
# 是否开启压缩,可以设置为 none, brotli, gzip # 是否开启压缩,可以设置为 none, brotli, gzip
VITE_COMPRESS=none VITE_COMPRESS=none

View File

@@ -0,0 +1,719 @@
<template>
<div class="p-4">
<PageWrapper>
<!-- 头部面包屑和操作 -->
<div class="mb-4 flex items-center justify-between">
<Breadcrumb>
<Breadcrumb.Item>
<a @click="router.back()">报销管理</a>
</Breadcrumb.Item>
<Breadcrumb.Item>报销详情</Breadcrumb.Item>
</Breadcrumb>
<Space>
<Button v-if="canEdit" type="primary" @click="handleEdit">
<Icon icon="mdi:pencil" class="mr-1" />
编辑
</Button>
<Button v-if="canSubmit" type="primary" @click="handleSubmit">
<Icon icon="mdi:send" class="mr-1" />
提交审批
</Button>
<Button v-if="canRevoke" danger @click="handleRevoke">
<Icon icon="mdi:undo" class="mr-1" />
撤回
</Button>
<Dropdown :trigger="['click']">
<template #overlay>
<Menu @click="handleMenuClick">
<Menu.Item key="export">
<Icon icon="mdi:download" class="mr-2" />
导出PDF
</Menu.Item>
<Menu.Item key="print">
<Icon icon="mdi:printer" class="mr-2" />
打印
</Menu.Item>
<Menu.Item key="copy">
<Icon icon="mdi:content-copy" class="mr-2" />
复制
</Menu.Item>
<Menu.Divider v-if="canDelete" />
<Menu.Item v-if="canDelete" key="delete" danger>
<Icon icon="mdi:delete" class="mr-2" />
删除
</Menu.Item>
</Menu>
</template>
<Button>
更多操作
<Icon icon="mdi:chevron-down" class="ml-1" />
</Button>
</Dropdown>
</Space>
</div>
<!-- 报销单基本信息 -->
<Card class="mb-4">
<div class="flex items-start justify-between mb-6">
<div class="flex-1">
<div class="flex items-center mb-4">
<h2 class="text-2xl font-bold mr-4">{{ reimbursement.reimbursementNo }}</h2>
<Tag :color="getStatusColor(reimbursement.status)" class="text-base px-3 py-1">
<Icon :icon="getStatusIcon(reimbursement.status)" class="mr-1" />
{{ getStatusText(reimbursement.status) }}
</Tag>
</div>
<Descriptions :column="3" bordered>
<Descriptions.Item label="申请人">
<div class="flex items-center">
<Avatar :size="32" :style="{ backgroundColor: '#1890ff' }">
{{ reimbursement.applicant.substring(0, 1) }}
</Avatar>
<div class="ml-2">
<div class="font-semibold">{{ reimbursement.applicant }}</div>
<div class="text-xs text-gray-400">{{ reimbursement.department }}</div>
</div>
</div>
</Descriptions.Item>
<Descriptions.Item label="申请日期">{{ reimbursement.applyDate }}</Descriptions.Item>
<Descriptions.Item label="报销金额">
<span class="text-2xl font-bold text-red-600">¥{{ formatNumber(reimbursement.amount) }}</span>
</Descriptions.Item>
<Descriptions.Item label="费用类型" :span="2">
<Space>
<Tag v-for="cat in reimbursement.categories" :key="cat" color="blue">{{ cat }}</Tag>
</Space>
</Descriptions.Item>
<Descriptions.Item label="费用项数">{{ reimbursement.items.length }} </Descriptions.Item>
<Descriptions.Item label="报销事由" :span="3">
{{ reimbursement.reason }}
</Descriptions.Item>
<Descriptions.Item label="备注" :span="3">
{{ reimbursement.notes || '无' }}
</Descriptions.Item>
</Descriptions>
</div>
</div>
<!-- 审批进度 -->
<div class="mt-6">
<h3 class="text-lg font-semibold mb-4">审批进度</h3>
<Steps :current="currentStep" :status="stepsStatus">
<Steps.Step
v-for="(step, index) in approvalSteps"
:key="index"
:title="step.title"
:description="step.description"
>
<template #icon>
<Icon v-if="step.status === 'finish'" icon="mdi:check-circle" class="text-green-500" />
<Icon v-else-if="step.status === 'error'" icon="mdi:close-circle" class="text-red-500" />
<Icon v-else-if="step.status === 'process'" icon="mdi:clock-outline" class="text-blue-500" />
<Icon v-else icon="mdi:circle-outline" class="text-gray-400" />
</template>
</Steps.Step>
</Steps>
</div>
</Card>
<!-- 费用明细 -->
<Card title="费用明细" class="mb-4">
<Table
:columns="itemColumns"
:dataSource="reimbursement.items"
:pagination="false"
:scroll="{ x: 1000 }"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'index'">
{{ index + 1 }}
</template>
<template v-else-if="column.dataIndex === 'category'">
<Tag color="blue">{{ record.category }}</Tag>
</template>
<template v-else-if="column.dataIndex === 'amount'">
<span class="font-semibold">¥{{ formatNumber(record.amount) }}</span>
</template>
<template v-else-if="column.dataIndex === 'receipt'">
<Tag :color="record.hasReceipt ? 'success' : 'warning'">
{{ record.hasReceipt ? '已上传' : '未上传' }}
</Tag>
</template>
</template>
<template #summary>
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell :index="0" :colSpan="5" class="text-right font-bold">
合计金额:
</Table.Summary.Cell>
<Table.Summary.Cell :index="5" class="font-bold text-lg text-red-600">
¥{{ formatNumber(totalAmount) }}
</Table.Summary.Cell>
<Table.Summary.Cell :index="6" />
</Table.Summary.Row>
</Table.Summary>
</template>
</Table>
</Card>
<!-- 附件列表 -->
<Card title="附件资料" class="mb-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div
v-for="(attachment, index) in reimbursement.attachments"
:key="index"
class="border rounded-lg p-3 hover:shadow-md transition-shadow cursor-pointer"
@click="previewAttachment(attachment)"
>
<div class="flex items-center">
<Icon
:icon="getFileIcon(attachment.type)"
class="text-3xl mr-3"
:style="{ color: getFileColor(attachment.type) }"
/>
<div class="flex-1 overflow-hidden">
<div class="text-sm font-semibold truncate">{{ attachment.name }}</div>
<div class="text-xs text-gray-400">{{ attachment.size }}</div>
</div>
</div>
</div>
</div>
<Empty v-if="reimbursement.attachments.length === 0" description="暂无附件" />
</Card>
<!-- 审批记录 -->
<Card title="审批记录" class="mb-4">
<Timeline>
<Timeline.Item
v-for="(record, index) in approvalRecords"
:key="index"
:color="getTimelineColor(record.action)"
>
<template #dot>
<Icon :icon="getActionIcon(record.action)" class="text-lg" />
</template>
<div class="pb-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<Avatar :size="32" :style="{ backgroundColor: getRandomColor(record.operator) }">
{{ record.operator.substring(0, 1) }}
</Avatar>
<div class="ml-2">
<span class="font-semibold">{{ record.operator }}</span>
<span class="text-gray-500 ml-2">{{ record.role }}</span>
</div>
</div>
<span class="text-gray-400 text-sm">{{ record.time }}</span>
</div>
<div class="ml-10">
<Tag :color="getActionColor(record.action)">{{ getActionText(record.action) }}</Tag>
<div v-if="record.comment" class="mt-2 text-gray-600 bg-gray-50 p-3 rounded">
{{ record.comment }}
</div>
</div>
</div>
</Timeline.Item>
</Timeline>
</Card>
<!-- 审批操作区仅审批人可见 -->
<Card v-if="showApprovalActions" title="审批操作" class="mb-4">
<div class="max-w-2xl">
<Form :model="approvalForm" layout="vertical">
<Form.Item label="审批意见">
<Input.TextArea
v-model:value="approvalForm.comment"
:rows="4"
placeholder="请输入审批意见..."
/>
</Form.Item>
<Form.Item>
<Space size="large">
<Button
type="primary"
size="large"
@click="handleApprove('approved')"
:loading="approving"
>
<Icon icon="mdi:check-circle" class="mr-1" />
通过
</Button>
<Button
danger
size="large"
@click="handleApprove('rejected')"
:loading="approving"
>
<Icon icon="mdi:close-circle" class="mr-1" />
拒绝
</Button>
<Button size="large" @click="handleApprove('transfer')">
<Icon icon="mdi:share" class="mr-1" />
转交
</Button>
</Space>
</Form.Item>
</Form>
</div>
</Card>
</PageWrapper>
<!-- 附件预览Modal -->
<Modal
v-model:open="previewVisible"
:title="previewFile?.name"
width="80%"
:footer="null"
>
<div class="flex items-center justify-center h-96">
<img v-if="isImage(previewFile?.type)" :src="previewFile?.url" class="max-h-full" />
<div v-else class="text-center">
<Icon :icon="getFileIcon(previewFile?.type)" class="text-8xl mb-4" />
<p>{{ previewFile?.name }}</p>
<Button type="primary" class="mt-4" @click="downloadFile(previewFile)">
<Icon icon="mdi:download" class="mr-1" />
下载文件
</Button>
</div>
</div>
</Modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { PageWrapper } from '@vben/common-ui';
import {
Card, Button, Space, Tag, Descriptions, Steps, Table, Timeline,
Avatar, Breadcrumb, Dropdown, Menu, Form, Input, Modal, Empty, message
} from 'ant-design-vue';
import { Icon } from '@iconify/vue';
defineOptions({ name: 'ReimbursementDetail' });
const router = useRouter();
const route = useRoute();
const previewVisible = ref(false);
const previewFile = ref<any>(null);
const approving = ref(false);
// 审批表单
const approvalForm = ref({
comment: ''
});
// 模拟报销单数据
const reimbursement = ref({
id: '1',
reimbursementNo: 'RE202501001',
applicant: '张三',
department: '技术部',
applyDate: '2025-01-08',
amount: 3850.00,
categories: ['差旅', '餐饮', '交通'],
reason: '客户现场技术支持差旅费用报销',
notes: '北京客户现场为期3天的技术支持工作',
status: 'pending',
items: [
{
key: '1',
date: '2025-01-05',
category: '交通',
description: '北京往返高铁票',
amount: 1200.00,
hasReceipt: true
},
{
key: '2',
date: '2025-01-05',
category: '住宿',
description: '北京希尔顿酒店 2晚',
amount: 1800.00,
hasReceipt: true
},
{
key: '3',
date: '2025-01-06',
category: '餐饮',
description: '客户商务晚餐',
amount: 650.00,
hasReceipt: true
},
{
key: '4',
date: '2025-01-07',
category: '交通',
description: '北京市内打车费用',
amount: 200.00,
hasReceipt: true
}
],
attachments: [
{
name: '高铁票.jpg',
type: 'image',
size: '2.5MB',
url: 'https://via.placeholder.com/800x600'
},
{
name: '酒店发票.pdf',
type: 'pdf',
size: '1.2MB',
url: ''
},
{
name: '餐饮发票.jpg',
type: 'image',
size: '1.8MB',
url: 'https://via.placeholder.com/800x600'
}
]
});
// 审批步骤
const approvalSteps = ref([
{
title: '提交申请',
description: '张三 · 2025-01-08 09:30',
status: 'finish'
},
{
title: '部门经理审批',
description: '李经理 · 审批中',
status: 'process'
},
{
title: '财务审核',
description: '待审核',
status: 'wait'
},
{
title: '总经理审批',
description: '待审批',
status: 'wait'
},
{
title: '财务支付',
description: '待支付',
status: 'wait'
}
]);
// 审批记录
const approvalRecords = ref([
{
operator: '张三',
role: '申请人',
action: 'submit',
time: '2025-01-08 09:30:00',
comment: '提交报销申请'
},
{
operator: '李经理',
role: '部门经理',
action: 'review',
time: '2025-01-08 14:20:00',
comment: '正在审核中,请补充商务晚餐的详细说明'
}
]);
// 费用明细表格列
const itemColumns = [
{
title: '序号',
dataIndex: 'index',
key: 'index',
width: 80
},
{
title: '日期',
dataIndex: 'date',
key: 'date',
width: 120
},
{
title: '费用类型',
dataIndex: 'category',
key: 'category',
width: 120
},
{
title: '费用说明',
dataIndex: 'description',
key: 'description'
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
width: 120
},
{
title: '发票状态',
dataIndex: 'receipt',
key: 'receipt',
width: 100
}
];
// 计算属性
const totalAmount = computed(() => {
return reimbursement.value.items.reduce((sum, item) => sum + item.amount, 0);
});
const currentStep = computed(() => {
const finishIndex = approvalSteps.value.findIndex(step => step.status === 'process');
return finishIndex >= 0 ? finishIndex : approvalSteps.value.length;
});
const stepsStatus = computed(() => {
if (reimbursement.value.status === 'rejected') return 'error';
if (reimbursement.value.status === 'paid') return 'finish';
return 'process';
});
const canEdit = computed(() => {
return reimbursement.value.status === 'draft' || reimbursement.value.status === 'rejected';
});
const canSubmit = computed(() => {
return reimbursement.value.status === 'draft';
});
const canRevoke = computed(() => {
return reimbursement.value.status === 'pending';
});
const canDelete = computed(() => {
return reimbursement.value.status === 'draft' || reimbursement.value.status === 'rejected';
});
const showApprovalActions = computed(() => {
// 检查URL参数或当前用户是否为审批人
return route.query.action === 'approve' && reimbursement.value.status === 'pending';
});
// 方法
const formatNumber = (num: number) => {
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(num);
};
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
'draft': 'default',
'pending': 'processing',
'approved': 'success',
'rejected': 'error',
'paid': 'success'
};
return colorMap[status] || 'default';
};
const getStatusIcon = (status: string) => {
const iconMap: Record<string, string> = {
'draft': 'mdi:file-document-edit-outline',
'pending': 'mdi:clock-outline',
'approved': 'mdi:check-circle',
'rejected': 'mdi:close-circle',
'paid': 'mdi:cash-check'
};
return iconMap[status] || 'mdi:help-circle';
};
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
'draft': '草稿',
'pending': '待审批',
'approved': '已通过',
'rejected': '已拒绝',
'paid': '已支付'
};
return textMap[status] || status;
};
const getFileIcon = (type: string) => {
const iconMap: Record<string, string> = {
'image': 'mdi:file-image',
'pdf': 'mdi:file-pdf-box',
'excel': 'mdi:file-excel',
'word': 'mdi:file-word'
};
return iconMap[type] || 'mdi:file-document';
};
const getFileColor = (type: string) => {
const colorMap: Record<string, string> = {
'image': '#52c41a',
'pdf': '#f5222d',
'excel': '#13c2c2',
'word': '#1890ff'
};
return colorMap[type] || '#666';
};
const isImage = (type?: string) => {
return type === 'image';
};
const getTimelineColor = (action: string) => {
const colorMap: Record<string, string> = {
'submit': 'blue',
'review': 'orange',
'approved': 'green',
'rejected': 'red',
'transfer': 'purple'
};
return colorMap[action] || 'gray';
};
const getActionIcon = (action: string) => {
const iconMap: Record<string, string> = {
'submit': 'mdi:send',
'review': 'mdi:eye',
'approved': 'mdi:check-circle',
'rejected': 'mdi:close-circle',
'transfer': 'mdi:share'
};
return iconMap[action] || 'mdi:circle';
};
const getActionColor = (action: string) => {
const colorMap: Record<string, string> = {
'submit': 'blue',
'review': 'orange',
'approved': 'success',
'rejected': 'error',
'transfer': 'purple'
};
return colorMap[action] || 'default';
};
const getActionText = (action: string) => {
const textMap: Record<string, string> = {
'submit': '提交申请',
'review': '审核中',
'approved': '已通过',
'rejected': '已拒绝',
'transfer': '转交'
};
return textMap[action] || action;
};
const getRandomColor = (str: string) => {
const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1'];
const hash = str.split('').reduce((acc, char) => char.charCodeAt(0) + acc, 0);
return colors[hash % colors.length];
};
// 事件处理
const handleEdit = () => {
router.push(`/reimbursement/create?id=${reimbursement.value.id}&mode=edit`);
};
const handleSubmit = () => {
Modal.confirm({
title: '确认提交审批',
content: '提交后将无法修改,是否确认提交审批?',
onOk: () => {
message.success('提交成功');
router.back();
}
});
};
const handleRevoke = () => {
Modal.confirm({
title: '确认撤回',
content: '是否确认撤回此报销单?',
onOk: () => {
message.success('撤回成功');
router.back();
}
});
};
const handleMenuClick = ({ key }: { key: string }) => {
switch (key) {
case 'export':
message.info('正在导出PDF...');
break;
case 'print':
window.print();
break;
case 'copy':
message.success('复制成功');
break;
case 'delete':
Modal.confirm({
title: '确认删除',
content: '删除后无法恢复,是否确认删除?',
okType: 'danger',
onOk: () => {
message.success('删除成功');
router.back();
}
});
break;
}
};
const previewAttachment = (attachment: any) => {
previewFile.value = attachment;
previewVisible.value = true;
};
const downloadFile = (file: any) => {
message.info(`正在下载 ${file.name}...`);
};
const handleApprove = async (action: string) => {
if (!approvalForm.value.comment && action === 'rejected') {
message.warning('拒绝时必须填写审批意见');
return;
}
Modal.confirm({
title: action === 'approved' ? '确认通过' : action === 'rejected' ? '确认拒绝' : '确认转交',
content: `是否确认${action === 'approved' ? '通过' : action === 'rejected' ? '拒绝' : '转交'}此报销申请?`,
onOk: async () => {
approving.value = true;
// 模拟API调用
setTimeout(() => {
approving.value = false;
message.success('操作成功');
router.back();
}, 1000);
}
});
};
onMounted(() => {
// 根据路由参数加载报销单详情
const id = route.params.id;
console.log('加载报销单详情:', id);
});
</script>
<style scoped>
:deep(.ant-descriptions-item-label) {
font-weight: 600;
background-color: #fafafa;
}
:deep(.ant-steps-item-description) {
font-size: 12px;
}
@media print {
.no-print {
display: none !important;
}
}
</style>

View File

@@ -0,0 +1,780 @@
<template>
<div class="p-4">
<PageWrapper title="报销管理" content="全面的报销单管理系统,支持创建、审批、追踪报销流程">
<!-- 顶部操作栏 -->
<Card class="mb-4">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-4">
<Input.Search
v-model:value="searchText"
placeholder="搜索报销单号、申请人、事由..."
style="width: 350px"
@search="onSearch"
/>
<Select v-model:value="filterStatus" style="width: 140px" placeholder="状态筛选" @change="onFilterChange">
<Select.Option value="">全部状态</Select.Option>
<Select.Option value="draft">草稿</Select.Option>
<Select.Option value="pending">待审批</Select.Option>
<Select.Option value="approved">已通过</Select.Option>
<Select.Option value="rejected">已拒绝</Select.Option>
<Select.Option value="paid">已支付</Select.Option>
<Select.Option value="cancelled">已取消</Select.Option>
</Select>
<Select v-model:value="filterDepartment" style="width: 150px" placeholder="部门筛选" @change="onFilterChange">
<Select.Option value="">全部部门</Select.Option>
<Select.Option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</Select.Option>
</Select>
<RangePicker v-model:value="dateFilter" placeholder="['申请日期', '结束日期']" @change="onDateChange" />
</div>
<div class="flex items-center space-x-2">
<Button type="primary" @click="goToCreate">
<Icon icon="mdi:plus" class="mr-1" />
创建报销单
</Button>
<Dropdown :trigger="['click']">
<template #overlay>
<Menu @click="handleExport">
<Menu.Item key="excel">导出Excel</Menu.Item>
<Menu.Item key="pdf">导出PDF</Menu.Item>
<Menu.Item key="selected">导出选中</Menu.Item>
</Menu>
</template>
<Button>
<Icon icon="mdi:download" class="mr-1" />
导出
<Icon icon="mdi:chevron-down" class="ml-1" />
</Button>
</Dropdown>
</div>
</div>
<!-- 快捷筛选标签 -->
<div class="flex items-center space-x-2">
<span class="text-gray-500 text-sm">快捷筛选:</span>
<Tag
:color="quickFilter === '' ? 'blue' : 'default'"
class="cursor-pointer"
@click="quickFilter = ''; loadReimbursements()"
>
全部 ({{ statistics.total }})
</Tag>
<Tag
:color="quickFilter === 'my' ? 'blue' : 'default'"
class="cursor-pointer"
@click="quickFilter = 'my'; loadReimbursements()"
>
我的报销 ({{ statistics.myTotal }})
</Tag>
<Tag
:color="quickFilter === 'pending' ? 'orange' : 'default'"
class="cursor-pointer"
@click="quickFilter = 'pending'; loadReimbursements()"
>
待我审批 ({{ statistics.pendingApproval }})
</Tag>
<Tag
:color="quickFilter === 'approved' ? 'green' : 'default'"
class="cursor-pointer"
@click="quickFilter = 'approved'; loadReimbursements()"
>
已通过 ({{ statistics.approved }})
</Tag>
</div>
</Card>
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card class="text-center hover:shadow-lg transition-shadow">
<Statistic
title="本月报销总额"
:value="statistics.monthTotal"
:precision="2"
prefix="¥"
value-style="color: #1890ff"
/>
<div class="text-xs text-gray-400 mt-2">较上月 +15.2%</div>
</Card>
<Card class="text-center hover:shadow-lg transition-shadow">
<Statistic
title="待审批金额"
:value="statistics.pendingAmount"
:precision="2"
prefix="¥"
value-style="color: #faad14"
/>
<div class="text-xs text-gray-400 mt-2">{{ statistics.pendingApproval }} 笔待审</div>
</Card>
<Card class="text-center hover:shadow-lg transition-shadow">
<Statistic
title="已支付金额"
:value="statistics.paidAmount"
:precision="2"
prefix="¥"
value-style="color: #52c41a"
/>
<div class="text-xs text-gray-400 mt-2">{{ statistics.paid }} 笔已支付</div>
</Card>
<Card class="text-center hover:shadow-lg transition-shadow">
<Statistic
title="平均处理时长"
:value="statistics.avgProcessTime"
suffix="天"
value-style="color: #722ed1"
/>
<div class="text-xs text-gray-400 mt-2">审批效率良好</div>
</Card>
</div>
<!-- 报销单列表 -->
<Card title="报销单列表">
<template #extra>
<Space>
<Tooltip title="刷新数据">
<Button @click="loadReimbursements" :loading="loading">
<Icon icon="mdi:refresh" />
</Button>
</Tooltip>
<Tooltip title="列设置">
<Button @click="showColumnSetting = true">
<Icon icon="mdi:cog" />
</Button>
</Tooltip>
</Space>
</template>
<Table
:columns="columns"
:dataSource="filteredReimbursements"
:loading="loading"
:scroll="{ x: 1500 }"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total, range) => `显示 ${range[0]}-${range[1]} 条,共 ${total} 条`
}"
:rowSelection="rowSelection"
@change="handleTableChange"
>
<!-- 自定义列模板 -->
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'reimbursementNo'">
<a @click="viewDetail(record)" class="text-blue-600 hover:text-blue-800">
{{ record.reimbursementNo }}
</a>
</template>
<template v-else-if="column.dataIndex === 'amount'">
<span class="font-semibold text-lg" :class="getAmountColor(record.amount)">
¥{{ formatNumber(record.amount) }}
</span>
</template>
<template v-else-if="column.dataIndex === 'status'">
<Tag :color="getStatusColor(record.status)">
<Icon :icon="getStatusIcon(record.status)" class="mr-1" />
{{ getStatusText(record.status) }}
</Tag>
</template>
<template v-else-if="column.dataIndex === 'applicant'">
<div class="flex items-center">
<Avatar :size="32" :style="{ backgroundColor: getRandomColor(record.applicant) }">
{{ record.applicant.substring(0, 1) }}
</Avatar>
<div class="ml-2">
<div>{{ record.applicant }}</div>
<div class="text-xs text-gray-400">{{ record.department }}</div>
</div>
</div>
</template>
<template v-else-if="column.dataIndex === 'items'">
<div class="text-xs">
<div>{{ record.itemsCount }} 项费用</div>
<div class="text-gray-400">{{ record.categories.join(', ') }}</div>
</div>
</template>
<template v-else-if="column.dataIndex === 'progress'">
<Tooltip :title="`${record.progress}% 完成`">
<Progress
:percent="record.progress"
:status="record.status === 'rejected' ? 'exception' : 'normal'"
size="small"
/>
</Tooltip>
</template>
<template v-else-if="column.dataIndex === 'approver'">
<div class="text-sm">
<div>{{ record.currentApprover || '-' }}</div>
<div class="text-xs text-gray-400">{{ record.approvalStep || '-' }}</div>
</div>
</template>
<template v-else-if="column.dataIndex === 'action'">
<Space>
<Tooltip title="查看详情">
<Button type="link" size="small" @click="viewDetail(record)">
<Icon icon="mdi:eye" />
</Button>
</Tooltip>
<Tooltip v-if="canEdit(record)" title="编辑">
<Button type="link" size="small" @click="editReimbursement(record)">
<Icon icon="mdi:pencil" />
</Button>
</Tooltip>
<Tooltip v-if="canApprove(record)" title="审批">
<Button type="link" size="small" @click="approveReimbursement(record)">
<Icon icon="mdi:check-circle" />
</Button>
</Tooltip>
<Dropdown :trigger="['click']">
<template #overlay>
<Menu @click="({ key }) => handleAction(key, record)">
<Menu.Item v-if="canSubmit(record)" key="submit">
<Icon icon="mdi:send" class="mr-2" />提交审批
</Menu.Item>
<Menu.Item v-if="canRevoke(record)" key="revoke">
<Icon icon="mdi:undo" class="mr-2" />撤回
</Menu.Item>
<Menu.Item key="export">
<Icon icon="mdi:download" class="mr-2" />导出
</Menu.Item>
<Menu.Item key="copy">
<Icon icon="mdi:content-copy" class="mr-2" />复制
</Menu.Item>
<Menu.Divider v-if="canDelete(record)" />
<Menu.Item v-if="canDelete(record)" key="delete" danger>
<Icon icon="mdi:delete" class="mr-2" />删除
</Menu.Item>
</Menu>
</template>
<Button type="link" size="small">
<Icon icon="mdi:dots-vertical" />
</Button>
</Dropdown>
</Space>
</template>
</template>
</Table>
</Card>
<!-- 批量操作浮动按钮 -->
<div v-if="selectedRowKeys.length > 0" class="fixed bottom-8 right-8 z-50">
<Card class="shadow-2xl">
<div class="flex items-center space-x-4">
<span class="text-sm">已选择 <span class="font-bold text-blue-600">{{ selectedRowKeys.length }}</span> </span>
<Button type="primary" @click="batchApprove" :disabled="!canBatchApprove">
批量审批
</Button>
<Button @click="batchExport">批量导出</Button>
<Button danger @click="batchDelete" :disabled="!canBatchDelete">批量删除</Button>
<Button @click="selectedRowKeys = []">取消选择</Button>
</div>
</Card>
</div>
</PageWrapper>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { PageWrapper } from '@vben/common-ui';
import {
Card, Table, Button, Input, Select, RangePicker, Space, Tag,
Tooltip, Dropdown, Menu, Statistic, Progress, Avatar, Modal, message
} from 'ant-design-vue';
import { Icon } from '@iconify/vue';
import dayjs from 'dayjs';
defineOptions({ name: 'ReimbursementManagement' });
const router = useRouter();
// 状态管理
const searchText = ref('');
const filterStatus = ref('');
const filterDepartment = ref('');
const dateFilter = ref();
const quickFilter = ref('');
const loading = ref(false);
const showColumnSetting = ref(false);
const selectedRowKeys = ref<string[]>([]);
// 分页
const pagination = ref({
current: 1,
pageSize: 10,
total: 0
});
// 部门列表
const departments = ref(['技术部', '市场部', '财务部', '人事部', '行政部', '销售部']);
// 统计数据
const statistics = ref({
total: 156,
myTotal: 23,
pendingApproval: 8,
approved: 98,
monthTotal: 285690.50,
pendingAmount: 45230.00,
paidAmount: 240460.50,
paid: 98,
avgProcessTime: 2.5
});
// 报销单数据
const reimbursements = ref([
{
key: '1',
reimbursementNo: 'RE202501001',
applicant: '张三',
department: '技术部',
applyDate: '2025-01-08',
amount: 3850.00,
itemsCount: 5,
categories: ['差旅', '餐饮'],
reason: '客户现场技术支持差旅费',
status: 'pending',
progress: 50,
currentApprover: '李经理',
approvalStep: '部门经理审批',
attachments: 3,
createTime: '2025-01-08 09:30:00',
updateTime: '2025-01-08 14:20:00'
},
{
key: '2',
reimbursementNo: 'RE202501002',
applicant: '李四',
department: '市场部',
applyDate: '2025-01-07',
amount: 12600.00,
itemsCount: 8,
categories: ['市场活动', '餐饮', '交通'],
reason: '产品发布会活动费用',
status: 'approved',
progress: 100,
currentApprover: '-',
approvalStep: '已完成',
attachments: 15,
createTime: '2025-01-07 10:15:00',
updateTime: '2025-01-08 16:40:00'
},
{
key: '3',
reimbursementNo: 'RE202501003',
applicant: '王五',
department: '技术部',
applyDate: '2025-01-09',
amount: 5200.00,
itemsCount: 3,
categories: ['办公用品', '设备'],
reason: '团队办公设备采购',
status: 'draft',
progress: 0,
currentApprover: '-',
approvalStep: '未提交',
attachments: 2,
createTime: '2025-01-09 11:00:00',
updateTime: '2025-01-09 11:00:00'
},
{
key: '4',
reimbursementNo: 'RE202501004',
applicant: '赵六',
department: '销售部',
applyDate: '2025-01-06',
amount: 8900.00,
itemsCount: 6,
categories: ['差旅', '住宿', '交通'],
reason: '客户拜访及商务洽谈',
status: 'paid',
progress: 100,
currentApprover: '-',
approvalStep: '已支付',
attachments: 8,
createTime: '2025-01-06 08:45:00',
updateTime: '2025-01-07 10:30:00'
},
{
key: '5',
reimbursementNo: 'RE202501005',
applicant: '陈七',
department: '市场部',
applyDate: '2025-01-05',
amount: 2800.00,
itemsCount: 4,
categories: ['餐饮', '礼品'],
reason: '客户接待费用',
status: 'rejected',
progress: 30,
currentApprover: '王总监',
approvalStep: '财务审批已拒绝',
attachments: 4,
createTime: '2025-01-05 14:20:00',
updateTime: '2025-01-05 17:10:00'
}
]);
// 表格列配置
const columns = [
{
title: '报销单号',
dataIndex: 'reimbursementNo',
key: 'reimbursementNo',
width: 140,
fixed: 'left'
},
{
title: '申请人',
dataIndex: 'applicant',
key: 'applicant',
width: 150
},
{
title: '申请日期',
dataIndex: 'applyDate',
key: 'applyDate',
width: 110,
sorter: true
},
{
title: '报销金额',
dataIndex: 'amount',
key: 'amount',
width: 130,
sorter: true
},
{
title: '费用明细',
dataIndex: 'items',
key: 'items',
width: 180
},
{
title: '事由',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
width: 200
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 110,
filters: [
{ text: '草稿', value: 'draft' },
{ text: '待审批', value: 'pending' },
{ text: '已通过', value: 'approved' },
{ text: '已拒绝', value: 'rejected' },
{ text: '已支付', value: 'paid' }
]
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
width: 120
},
{
title: '当前审批人',
dataIndex: 'approver',
key: 'approver',
width: 130
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right'
}
];
// 过滤后的数据
const filteredReimbursements = computed(() => {
let filtered = reimbursements.value;
// 搜索过滤
if (searchText.value) {
const search = searchText.value.toLowerCase();
filtered = filtered.filter(r =>
r.reimbursementNo.toLowerCase().includes(search) ||
r.applicant.toLowerCase().includes(search) ||
r.reason.toLowerCase().includes(search)
);
}
// 状态过滤
if (filterStatus.value) {
filtered = filtered.filter(r => r.status === filterStatus.value);
}
// 部门过滤
if (filterDepartment.value) {
filtered = filtered.filter(r => r.department === filterDepartment.value);
}
// 快捷过滤
if (quickFilter.value === 'my') {
// 模拟:只显示当前用户的报销单
filtered = filtered.filter(r => r.applicant === '张三');
} else if (quickFilter.value === 'pending') {
filtered = filtered.filter(r => r.status === 'pending');
} else if (quickFilter.value === 'approved') {
filtered = filtered.filter(r => r.status === 'approved');
}
return filtered;
});
// 行选择配置
const rowSelection = {
selectedRowKeys: selectedRowKeys,
onChange: (keys: string[]) => {
selectedRowKeys.value = keys;
}
};
// 权限判断
const canEdit = (record: any) => {
return record.status === 'draft' || record.status === 'rejected';
};
const canApprove = (record: any) => {
return record.status === 'pending';
};
const canSubmit = (record: any) => {
return record.status === 'draft';
};
const canRevoke = (record: any) => {
return record.status === 'pending';
};
const canDelete = (record: any) => {
return record.status === 'draft' || record.status === 'rejected';
};
const canBatchApprove = computed(() => {
return selectedRowKeys.value.some(key => {
const record = reimbursements.value.find(r => r.key === key);
return record && record.status === 'pending';
});
});
const canBatchDelete = computed(() => {
return selectedRowKeys.value.every(key => {
const record = reimbursements.value.find(r => r.key === key);
return record && (record.status === 'draft' || record.status === 'rejected');
});
});
// 方法实现
const formatNumber = (num: number) => {
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(num);
};
const getAmountColor = (amount: number) => {
if (amount > 10000) return 'text-red-600';
if (amount > 5000) return 'text-orange-600';
return 'text-green-600';
};
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
'draft': 'default',
'pending': 'processing',
'approved': 'success',
'rejected': 'error',
'paid': 'success',
'cancelled': 'default'
};
return colorMap[status] || 'default';
};
const getStatusIcon = (status: string) => {
const iconMap: Record<string, string> = {
'draft': 'mdi:file-document-edit-outline',
'pending': 'mdi:clock-outline',
'approved': 'mdi:check-circle',
'rejected': 'mdi:close-circle',
'paid': 'mdi:cash-check',
'cancelled': 'mdi:cancel'
};
return iconMap[status] || 'mdi:help-circle';
};
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
'draft': '草稿',
'pending': '待审批',
'approved': '已通过',
'rejected': '已拒绝',
'paid': '已支付',
'cancelled': '已取消'
};
return textMap[status] || status;
};
const getRandomColor = (str: string) => {
const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2'];
const hash = str.split('').reduce((acc, char) => char.charCodeAt(0) + acc, 0);
return colors[hash % colors.length];
};
// 事件处理
const onSearch = () => {
pagination.value.current = 1;
loadReimbursements();
};
const onFilterChange = () => {
pagination.value.current = 1;
loadReimbursements();
};
const onDateChange = () => {
pagination.value.current = 1;
loadReimbursements();
};
const handleTableChange = (pag: any, filters: any, sorter: any) => {
pagination.value = pag;
console.log('表格变化:', pag, filters, sorter);
};
const loadReimbursements = () => {
loading.value = true;
setTimeout(() => {
loading.value = false;
pagination.value.total = filteredReimbursements.value.length;
}, 500);
};
const goToCreate = () => {
router.push('/reimbursement/create');
};
const viewDetail = (record: any) => {
router.push(`/reimbursement/detail/${record.key}`);
};
const editReimbursement = (record: any) => {
router.push(`/reimbursement/create?id=${record.key}&mode=edit`);
};
const approveReimbursement = (record: any) => {
router.push(`/reimbursement/detail/${record.key}?action=approve`);
};
const handleAction = (key: string, record: any) => {
switch (key) {
case 'submit':
Modal.confirm({
title: '确认提交审批',
content: `是否确认提交报销单 ${record.reimbursementNo} 进行审批?`,
onOk: () => {
message.success('提交成功');
loadReimbursements();
}
});
break;
case 'revoke':
Modal.confirm({
title: '确认撤回',
content: `是否确认撤回报销单 ${record.reimbursementNo}`,
onOk: () => {
message.success('撤回成功');
loadReimbursements();
}
});
break;
case 'export':
message.info('正在导出...');
break;
case 'copy':
message.success('复制成功');
break;
case 'delete':
Modal.confirm({
title: '确认删除',
content: `是否确认删除报销单 ${record.reimbursementNo}?此操作不可恢复。`,
okType: 'danger',
onOk: () => {
message.success('删除成功');
loadReimbursements();
}
});
break;
}
};
const handleExport = ({ key }: { key: string }) => {
message.info(`正在导出${key}格式...`);
};
const batchApprove = () => {
Modal.confirm({
title: '批量审批',
content: `是否确认批量审批选中的 ${selectedRowKeys.value.length} 个报销单?`,
onOk: () => {
message.success('批量审批成功');
selectedRowKeys.value = [];
loadReimbursements();
}
});
};
const batchExport = () => {
message.info(`正在导出选中的 ${selectedRowKeys.value.length} 个报销单...`);
};
const batchDelete = () => {
Modal.confirm({
title: '批量删除',
content: `是否确认删除选中的 ${selectedRowKeys.value.length} 个报销单?此操作不可恢复。`,
okType: 'danger',
onOk: () => {
message.success('批量删除成功');
selectedRowKeys.value = [];
loadReimbursements();
}
});
};
onMounted(() => {
loadReimbursements();
});
</script>
<style scoped>
:deep(.ant-table-thead > tr > th) {
background-color: #fafafa;
font-weight: 600;
}
:deep(.ant-statistic-content-value) {
font-size: 24px;
font-weight: 600;
}
.cursor-pointer {
cursor: pointer;
}
</style>

View File

@@ -1,6 +1,13 @@
import { requestClient } from '../request'; import { requestClient } from '../request';
export namespace FinanceApi { export namespace FinanceApi {
export type TransactionStatus =
| 'draft'
| 'pending'
| 'approved'
| 'rejected'
| 'paid';
// 货币类型 // 货币类型
export interface Currency { export interface Currency {
code: string; code: string;
@@ -71,6 +78,38 @@ export namespace FinanceApi {
createdAt: string; createdAt: string;
isDeleted?: boolean; isDeleted?: boolean;
deletedAt?: string; deletedAt?: string;
status: TransactionStatus;
statusUpdatedAt?: string;
reimbursementBatch?: string;
reviewNotes?: string;
submittedBy?: string;
approvedBy?: string;
approvedAt?: string;
}
export interface MediaMessage {
id: number;
chatId: number;
messageId: number;
userId: number;
username?: string;
displayName?: string;
fileType: string;
fileId: string;
fileUniqueId?: string;
caption?: string;
fileName?: string;
filePath: string;
fileSize?: number;
mimeType?: string;
duration?: number;
width?: number;
height?: number;
forwardedTo?: number;
createdAt: string;
updatedAt: string;
available: boolean;
downloadUrl: string | null;
} }
// 创建交易的参数 // 创建交易的参数
@@ -85,6 +124,34 @@ export namespace FinanceApi {
project?: string; project?: string;
memo?: string; memo?: string;
createdAt?: string; createdAt?: string;
status?: TransactionStatus;
reimbursementBatch?: string | null;
reviewNotes?: string | null;
submittedBy?: string | null;
approvedBy?: string | null;
approvedAt?: string | null;
statusUpdatedAt?: string;
}
export interface CreateReimbursementParams {
type?: 'expense' | 'income' | 'transfer';
amount: number;
currency?: string;
categoryId?: number;
accountId?: number;
transactionDate: string;
description?: string;
project?: string;
memo?: string;
createdAt?: string;
status?: TransactionStatus;
reimbursementBatch?: string | null;
reviewNotes?: string | null;
submittedBy?: string | null;
approvedBy?: string | null;
approvedAt?: string | null;
statusUpdatedAt?: string;
requester?: string | null;
} }
// 预算 // 预算
@@ -210,9 +277,21 @@ export namespace FinanceApi {
*/ */
export async function getTransactions(params?: { export async function getTransactions(params?: {
type?: 'expense' | 'income' | 'transfer'; type?: 'expense' | 'income' | 'transfer';
statuses?: TransactionStatus[];
includeDeleted?: boolean;
}) { }) {
const query: Record<string, any> = {};
if (params?.type) {
query.type = params.type;
}
if (params?.statuses && params.statuses.length > 0) {
query.statuses = params.statuses.join(',');
}
if (params?.includeDeleted !== undefined) {
query.includeDeleted = params.includeDeleted;
}
return requestClient.get<Transaction[]>('/finance/transactions', { return requestClient.get<Transaction[]>('/finance/transactions', {
params, params: query,
}); });
} }
@@ -233,6 +312,66 @@ export namespace FinanceApi {
return requestClient.put<Transaction>(`/finance/transactions/${id}`, data); return requestClient.put<Transaction>(`/finance/transactions/${id}`, data);
} }
/**
* 获取报销申请
*/
export async function getReimbursements(params?: {
type?: 'expense' | 'income' | 'transfer';
statuses?: TransactionStatus[];
includeDeleted?: boolean;
}) {
const query: Record<string, any> = {};
if (params?.type) {
query.type = params.type;
}
if (params?.statuses && params.statuses.length > 0) {
query.statuses = params.statuses.join(',');
}
if (params?.includeDeleted !== undefined) {
query.includeDeleted = params.includeDeleted;
}
return requestClient.get<Transaction[]>('/finance/reimbursements', {
params: query,
});
}
/**
* 创建报销申请
*/
export async function createReimbursement(
data: CreateReimbursementParams,
) {
return requestClient.post<Transaction>('/finance/reimbursements', data);
}
/**
* 更新报销申请
*/
export async function updateReimbursement(
id: number,
data: Partial<CreateReimbursementParams>,
) {
return requestClient.put<Transaction>(`/finance/reimbursements/${id}`, data);
}
/**
* 删除报销申请
*/
export async function deleteReimbursement(id: number) {
return requestClient.delete<{ message: string }>(
`/finance/transactions/${id}`,
);
}
/**
* 恢复报销申请
*/
export async function restoreReimbursement(id: number) {
return requestClient.put<Transaction>(`/finance/reimbursements/${id}`, {
isDeleted: false,
});
}
/** /**
* 软删除交易 * 软删除交易
*/ */
@@ -290,4 +429,30 @@ export namespace FinanceApi {
isDeleted: false, isDeleted: false,
}); });
} }
/**
* 获取媒体消息
*/
export async function getMediaMessages(params?: {
limit?: number;
fileTypes?: string[];
}) {
const query: Record<string, any> = {};
if (params?.limit) {
query.limit = params.limit;
}
if (params?.fileTypes && params.fileTypes.length > 0) {
query.types = params.fileTypes.join(',');
}
return requestClient.get<MediaMessage[]>('/finance/media', {
params: query,
});
}
/**
* 获取单条媒体消息详情
*/
export async function getMediaMessage(id: number) {
return requestClient.get<MediaMessage>(`/finance/media/${id}`);
}
} }

View File

@@ -49,8 +49,7 @@ const flattenFinWiseProMenu = () => {
if (!childrenUL || !parentMenu) return; if (!childrenUL || !parentMenu) return;
// Check if already processed // Check if already processed
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return;
return;
// Move all children to the parent menu // Move all children to the parent menu
const children = [...childrenUL.children]; const children = [...childrenUL.children];

View File

@@ -52,8 +52,7 @@ function flattenFinWiseProMenu() {
if (!childrenUL || !parentMenu) return; if (!childrenUL || !parentMenu) return;
// Check if already processed // Check if already processed
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return;
return;
// Move all children to the parent menu // Move all children to the parent menu
const children = [...childrenUL.children]; const children = [...childrenUL.children];

View File

@@ -55,10 +55,7 @@ router.afterEach(() => {
if (!childrenUL || !parentMenu) return; if (!childrenUL || !parentMenu) return;
// Check if already processed // Check if already processed
if ( if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return;
(finwiseMenu as HTMLElement).dataset.hideFinwise === 'true'
)
return;
// Move all children to the parent menu // Move all children to the parent menu
const children = [...childrenUL.children]; const children = [...childrenUL.children];

View File

@@ -79,6 +79,57 @@ const routes: RouteRecordRaw[] = [
title: '📈 报表分析', title: '📈 报表分析',
}, },
}, },
{
name: 'FinanceReimbursement',
path: '/reimbursement',
alias: ['/finance/reimbursement'],
component: () => import('#/views/finance/reimbursement/index.vue'),
meta: {
icon: 'mdi:file-document-outline',
order: 8,
title: '💼 报销管理',
},
},
{
name: 'ReimbursementDetail',
path: '/reimbursement/detail/:id',
component: () => import('#/views/finance/reimbursement/detail.vue'),
meta: {
hideInMenu: true,
icon: 'mdi:file-document',
title: '报销详情',
},
},
{
name: 'ReimbursementCreate',
path: '/reimbursement/create',
component: () => import('#/views/finance/reimbursement/create.vue'),
meta: {
hideInMenu: true,
icon: 'mdi:plus-circle',
title: '创建报销单',
},
},
{
name: 'ReimbursementApproval',
path: '/reimbursement/approval',
component: () => import('#/views/finance/reimbursement/approval.vue'),
meta: {
icon: 'mdi:checkbox-marked-circle-outline',
order: 9,
title: '📋 待审批',
},
},
{
name: 'ReimbursementStatistics',
path: '/reimbursement/statistics',
component: () => import('#/views/finance/reimbursement/statistics.vue'),
meta: {
icon: 'mdi:chart-bar',
order: 10,
title: '📊 报销统计',
},
},
{ {
name: 'FinanceTools', name: 'FinanceTools',
path: '/tools', path: '/tools',
@@ -86,10 +137,21 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/finance/tools/index.vue'), component: () => import('#/views/finance/tools/index.vue'),
meta: { meta: {
icon: 'mdi:tools', icon: 'mdi:tools',
order: 8, order: 11,
title: '🛠️ 财务工具', title: '🛠️ 财务工具',
}, },
}, },
{
name: 'FinanceMedia',
path: '/media',
alias: ['/finance/media'],
component: () => import('#/views/finance/media/index.vue'),
meta: {
icon: 'mdi:folder-multiple-image',
order: 12,
title: '🖼️ 媒体中心',
},
},
{ {
name: 'FinanceSettings', name: 'FinanceSettings',
path: '/fin-settings', path: '/fin-settings',
@@ -97,7 +159,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/finance/settings/index.vue'), component: () => import('#/views/finance/settings/index.vue'),
meta: { meta: {
icon: 'mdi:cog', icon: 'mdi:cog',
order: 9, order: 13,
title: '⚙️ 系统设置', title: '⚙️ 系统设置',
}, },
}, },

View File

@@ -13,6 +13,8 @@ export const useFinanceStore = defineStore('finance', () => {
const exchangeRates = ref<FinanceApi.ExchangeRate[]>([]); const exchangeRates = ref<FinanceApi.ExchangeRate[]>([]);
const transactions = ref<FinanceApi.Transaction[]>([]); const transactions = ref<FinanceApi.Transaction[]>([]);
const budgets = ref<FinanceApi.Budget[]>([]); const budgets = ref<FinanceApi.Budget[]>([]);
const reimbursements = ref<FinanceApi.Transaction[]>([]);
const mediaMessages = ref<FinanceApi.MediaMessage[]>([]);
// 加载状态 // 加载状态
const loading = ref({ const loading = ref({
@@ -22,6 +24,8 @@ export const useFinanceStore = defineStore('finance', () => {
exchangeRates: false, exchangeRates: false,
transactions: false, transactions: false,
budgets: false, budgets: false,
reimbursements: false,
mediaMessages: false,
}); });
// 获取货币列表 // 获取货币列表
@@ -131,15 +135,32 @@ export const useFinanceStore = defineStore('finance', () => {
} }
// 获取交易列表 // 获取交易列表
async function fetchTransactions() { async function fetchTransactions(params?: {
statuses?: FinanceApi.TransactionStatus[];
includeDeleted?: boolean;
type?: 'expense' | 'income' | 'transfer';
}) {
loading.value.transactions = true; loading.value.transactions = true;
try { try {
transactions.value = await FinanceApi.getTransactions(); transactions.value = await FinanceApi.getTransactions(params);
} finally { } finally {
loading.value.transactions = false; loading.value.transactions = false;
} }
} }
// 获取媒体消息
async function fetchMediaMessages(params?: {
limit?: number;
fileTypes?: string[];
}) {
loading.value.mediaMessages = true;
try {
mediaMessages.value = await FinanceApi.getMediaMessages(params);
} finally {
loading.value.mediaMessages = false;
}
}
// 创建交易 // 创建交易
async function createTransaction(data: FinanceApi.CreateTransactionParams) { async function createTransaction(data: FinanceApi.CreateTransactionParams) {
const transaction = await FinanceApi.createTransaction(data); const transaction = await FinanceApi.createTransaction(data);
@@ -195,6 +216,80 @@ export const useFinanceStore = defineStore('finance', () => {
return transaction; return transaction;
} }
// 获取报销申请
async function fetchReimbursements(params?: {
statuses?: FinanceApi.TransactionStatus[];
includeDeleted?: boolean;
type?: 'expense' | 'income' | 'transfer';
}) {
loading.value.reimbursements = true;
try {
reimbursements.value = await FinanceApi.getReimbursements(params);
} finally {
loading.value.reimbursements = false;
}
}
// 创建报销申请
async function createReimbursement(
data: FinanceApi.CreateReimbursementParams,
) {
const payload = {
...data,
type: data.type ?? 'expense',
currency: data.currency ?? 'CNY',
status: data.status ?? 'pending',
submittedBy: data.submittedBy ?? data.requester ?? null,
};
const reimbursement = await FinanceApi.createReimbursement(payload);
reimbursements.value.unshift(reimbursement);
return reimbursement;
}
// 更新报销申请
async function updateReimbursement(
id: number,
data: Partial<FinanceApi.CreateReimbursementParams>,
) {
const reimbursement = await FinanceApi.updateReimbursement(id, data);
const index = reimbursements.value.findIndex((item) => item.id === id);
if (index !== -1) {
reimbursements.value[index] = reimbursement;
} else {
reimbursements.value.unshift(reimbursement);
}
if (reimbursement.status === 'approved' || reimbursement.status === 'paid') {
await Promise.all([fetchTransactions(), fetchAccounts()]);
}
return reimbursement;
}
// 删除报销申请
async function deleteReimbursement(id: number) {
await FinanceApi.deleteReimbursement(id);
const index = reimbursements.value.findIndex((item) => item.id === id);
if (index !== -1) {
const current = reimbursements.value[index];
reimbursements.value[index] = {
...current,
isDeleted: true,
deletedAt: new Date().toISOString(),
};
}
}
// 恢复报销申请
async function restoreReimbursement(id: number) {
const reimbursement = await FinanceApi.restoreReimbursement(id);
const index = reimbursements.value.findIndex((item) => item.id === id);
if (index !== -1) {
reimbursements.value[index] = reimbursement;
} else {
reimbursements.value.unshift(reimbursement);
}
return reimbursement;
}
// 根据货币代码获取货币信息 // 根据货币代码获取货币信息
function getCurrencyByCode(code: string) { function getCurrencyByCode(code: string) {
return currencies.value.find((c) => c.code === code); return currencies.value.find((c) => c.code === code);
@@ -296,6 +391,8 @@ export const useFinanceStore = defineStore('finance', () => {
exchangeRates, exchangeRates,
transactions, transactions,
budgets, budgets,
reimbursements,
mediaMessages,
loading, loading,
// 方法 // 方法
@@ -307,10 +404,16 @@ export const useFinanceStore = defineStore('finance', () => {
fetchAccounts, fetchAccounts,
fetchExchangeRates, fetchExchangeRates,
fetchTransactions, fetchTransactions,
fetchMediaMessages,
createTransaction, createTransaction,
updateTransaction, updateTransaction,
softDeleteTransaction, softDeleteTransaction,
restoreTransaction, restoreTransaction,
fetchReimbursements,
createReimbursement,
updateReimbursement,
deleteReimbursement,
restoreReimbursement,
fetchBudgets, fetchBudgets,
createBudget, createBudget,
updateBudget, updateBudget,

View File

@@ -268,11 +268,11 @@ const resetForm = () => {
const getCurrencySymbol = (currency: string) => { const getCurrencySymbol = (currency: string) => {
const symbolMap: Record<string, string> = { const symbolMap: Record<string, string> = {
CNY: '¥', CNY: '$',
THB: '฿', THB: '฿',
USD: '$', USD: '$',
EUR: '€', EUR: '€',
JPY: '¥', JPY: '$',
GBP: '£', GBP: '£',
HKD: 'HK$', HKD: 'HK$',
KRW: '₩', KRW: '₩',

View File

@@ -1,25 +1,59 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Button, Card, InputNumber, Switch } from 'ant-design-vue';
defineOptions({ name: 'BillReminders' });
const showAddBill = ref(false);
// 今日账单(空数据)
const todayBills = ref([]);
// 所有账单(空数据)
const allBills = ref([]);
// 提醒设置
const reminderSettings = ref({
daysBefore: 3,
smsEnabled: true,
emailEnabled: false,
pushEnabled: true,
});
const saveReminderSettings = () => {
console.log('保存提醒设置:', reminderSettings.value);
};
</script>
<template> <template>
<div class="p-6"> <div class="p-6">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">🔔 账单提醒</h1> <h1 class="mb-2 text-3xl font-bold text-gray-900">🔔 账单提醒</h1>
<p class="text-gray-600">智能账单管理从此不错过任何缴费</p> <p class="text-gray-600">智能账单管理从此不错过任何缴费</p>
</div> </div>
<!-- 今日提醒 --> <!-- 今日提醒 -->
<Card class="mb-6" title="📅 今日待缴账单"> <Card class="mb-6" title="📅 今日待缴账单">
<div v-if="todayBills.length === 0" class="text-center py-8"> <div v-if="todayBills.length === 0" class="py-8 text-center">
<div class="text-6xl mb-4"></div> <div class="mb-4 text-6xl"></div>
<p class="text-green-600 font-medium">今天没有待缴账单</p> <p class="font-medium text-green-600">今天没有待缴账单</p>
<p class="text-sm text-gray-500">享受无忧的一天</p> <p class="text-sm text-gray-500">享受无忧的一天</p>
</div> </div>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div v-for="bill in todayBills" :key="bill.id" class="p-4 bg-red-50 border border-red-200 rounded-lg"> <div
v-for="bill in todayBills"
:key="bill.id"
class="rounded-lg border border-red-200 bg-red-50 p-4"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<span class="text-2xl">{{ bill.emoji }}</span> <span class="text-2xl">{{ bill.emoji }}</span>
<div> <div>
<p class="font-medium text-red-800">{{ bill.name }}</p> <p class="font-medium text-red-800">{{ bill.name }}</p>
<p class="text-sm text-red-600">今天到期 · ¥{{ bill.amount.toLocaleString() }}</p> <p class="text-sm text-red-600">
今天到期 · ${{ bill.amount.toLocaleString() }}
</p>
</div> </div>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
@@ -37,27 +71,33 @@
<Button type="primary" @click="showAddBill = true"> 添加账单</Button> <Button type="primary" @click="showAddBill = true"> 添加账单</Button>
</template> </template>
<div v-if="allBills.length === 0" class="text-center py-12"> <div v-if="allBills.length === 0" class="py-12 text-center">
<div class="text-8xl mb-6">📱</div> <div class="mb-6 text-8xl">📱</div>
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无账单记录</h3> <h3 class="mb-2 text-xl font-medium text-gray-800">暂无账单记录</h3>
<p class="text-gray-500 mb-6">添加您的常用账单系统将自动提醒</p> <p class="mb-6 text-gray-500">添加您的常用账单系统将自动提醒</p>
<Button type="primary" size="large" @click="showAddBill = true"> <Button type="primary" size="large" @click="showAddBill = true">
添加第一个账单 添加第一个账单
</Button> </Button>
</div> </div>
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<div v-for="bill in allBills" :key="bill.id" class="p-4 border border-gray-200 rounded-lg"> <div
v-for="bill in allBills"
:key="bill.id"
class="rounded-lg border border-gray-200 p-4"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<span class="text-2xl">{{ bill.emoji }}</span> <span class="text-2xl">{{ bill.emoji }}</span>
<div> <div>
<p class="font-medium">{{ bill.name }}</p> <p class="font-medium">{{ bill.name }}</p>
<p class="text-sm text-gray-500">{{ bill.provider }} · {{ bill.cycle }}缴费</p> <p class="text-sm text-gray-500">
{{ bill.provider }} · {{ bill.cycle }}缴费
</p>
</div> </div>
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="font-semibold">¥{{ bill.amount.toLocaleString() }}</p> <p class="font-semibold">${{ bill.amount.toLocaleString() }}</p>
<p class="text-sm text-gray-500">下次: {{ bill.nextDue }}</p> <p class="text-sm text-gray-500">下次: {{ bill.nextDue }}</p>
</div> </div>
</div> </div>
@@ -66,11 +106,13 @@
</Card> </Card>
<!-- 账单统计 --> <!-- 账单统计 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<Card title="📊 月度账单统计"> <Card title="📊 月度账单统计">
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center"> <div
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
>
<div class="text-center"> <div class="text-center">
<div class="text-4xl mb-2">📈</div> <div class="mb-2 text-4xl">📈</div>
<p class="text-gray-600">月度账单趋势</p> <p class="text-gray-600">月度账单趋势</p>
</div> </div>
</div> </div>
@@ -78,56 +120,37 @@
<Card title="⏰ 提醒设置"> <Card title="⏰ 提醒设置">
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span>提前提醒天数</span> <span>提前提醒天数</span>
<InputNumber v-model:value="reminderSettings.daysBefore" :min="1" :max="30" /> <InputNumber
v-model:value="reminderSettings.daysBefore"
:min="1"
:max="30"
/>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span>短信提醒</span> <span>短信提醒</span>
<Switch v-model:checked="reminderSettings.smsEnabled" /> <Switch v-model:checked="reminderSettings.smsEnabled" />
</div> </div>
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span>邮件提醒</span> <span>邮件提醒</span>
<Switch v-model:checked="reminderSettings.emailEnabled" /> <Switch v-model:checked="reminderSettings.emailEnabled" />
</div> </div>
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span>应用通知</span> <span>应用通知</span>
<Switch v-model:checked="reminderSettings.pushEnabled" /> <Switch v-model:checked="reminderSettings.pushEnabled" />
</div> </div>
<Button type="primary" block @click="saveReminderSettings">保存设置</Button> <Button type="primary" block @click="saveReminderSettings">
保存设置
</Button>
</div> </div>
</Card> </Card>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue';
import { Card, Button, InputNumber, Switch } from 'ant-design-vue';
defineOptions({ name: 'BillReminders' });
const showAddBill = ref(false);
// 今日账单(空数据)
const todayBills = ref([]);
// 所有账单(空数据)
const allBills = ref([]);
// 提醒设置
const reminderSettings = ref({
daysBefore: 3,
smsEnabled: true,
emailEnabled: false,
pushEnabled: true
});
const saveReminderSettings = () => {
console.log('保存提醒设置:', reminderSettings.value);
};
</script>
<style scoped> <style scoped>
.grid { display: grid; } .grid {
display: grid;
}
</style> </style>

View File

@@ -370,7 +370,7 @@ const setBudget = (category: any) => {
<div class="rounded-lg bg-purple-50 p-3 text-center"> <div class="rounded-lg bg-purple-50 p-3 text-center">
<p class="text-sm text-gray-500">预算总额</p> <p class="text-sm text-gray-500">预算总额</p>
<p class="text-xl font-bold text-purple-600"> <p class="text-xl font-bold text-purple-600">
¥{{ categoryStats.budgetTotal.toLocaleString() }} ${{ categoryStats.budgetTotal.toLocaleString() }}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -133,7 +133,7 @@ const baseCurrencySymbol = computed(() => {
const baseCurrency = financeStore.currencies.find( const baseCurrency = financeStore.currencies.find(
(currency) => currency.isBase, (currency) => currency.isBase,
); );
return baseCurrency?.symbol || '¥'; return baseCurrency?.symbol || '$';
}); });
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
@@ -203,9 +203,9 @@ const trendChartData = computed(() => {
bucketKeys.forEach((key) => { bucketKeys.forEach((key) => {
const bucket = bucketMap.get(key) ?? { income: 0, expense: 0 }; const bucket = bucketMap.get(key) ?? { income: 0, expense: 0 };
const label = useMonthlyBucket const label = useMonthlyBucket
? (english ? english
? dayjs(key).format('MMM') ? dayjs(key).format('MMM')
: dayjs(key).format('MM月')) : dayjs(key).format('MM月')
: dayjs(key).format('MM-DD'); : dayjs(key).format('MM-DD');
labels.push(label); labels.push(label);
const income = Number(bucket.income.toFixed(2)); const income = Number(bucket.income.toFixed(2));
@@ -1039,7 +1039,9 @@ onMounted(async () => {
:style="{ width: `${item.percentage}%` }" :style="{ width: `${item.percentage}%` }"
></div> ></div>
</div> </div>
<span class="w-12 text-right text-xs text-gray-500">{{ item.percentage }}%</span> <span class="w-12 text-right text-xs text-gray-500"
>{{ item.percentage }}%</span
>
</div> </div>
<div class="mt-1 text-xs text-gray-500"> <div class="mt-1 text-xs text-gray-500">
{{ item.count }} {{ isEnglish ? 'records' : '笔交易' }} {{ item.count }} {{ isEnglish ? 'records' : '笔交易' }}

View File

@@ -1,232 +1,20 @@
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">📱 费用追踪</h1>
<p class="text-gray-600">智能费用追踪支持小票OCR识别和自动分类</p>
</div>
<!-- 快速添加费用 -->
<Card class="mb-6" title="⚡ 快速记录">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- 拍照记录 -->
<div class="text-center p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 cursor-pointer" @click="openCamera">
<div class="text-4xl mb-3">📷</div>
<h3 class="font-medium mb-2">拍照记录</h3>
<p class="text-sm text-gray-500">拍摄小票自动识别金额和商家</p>
</div>
<!-- 语音记录 -->
<div class="text-center p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-green-400 cursor-pointer" @click="startVoiceRecord">
<div class="text-4xl mb-3">🎤</div>
<h3 class="font-medium mb-2">语音记录</h3>
<p class="text-sm text-gray-500">说出消费内容智能转换为记录</p>
</div>
<!-- 手动输入 -->
<div class="text-center p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-purple-400 cursor-pointer" @click="showQuickAdd = true">
<div class="text-4xl mb-3"></div>
<h3 class="font-medium mb-2">手动输入</h3>
<p class="text-sm text-gray-500">快速手动输入费用信息</p>
</div>
</div>
</Card>
<!-- 今日费用汇总 -->
<Card class="mb-6" title="📅 今日费用汇总">
<div v-if="todayExpenses.length === 0" class="text-center py-8">
<div class="text-6xl mb-4">💸</div>
<p class="text-gray-500 mb-4">今天还没有费用记录</p>
<Button type="primary" @click="openCamera">开始记录第一笔费用</Button>
</div>
<div v-else>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div class="text-center p-4 bg-red-50 rounded-lg">
<p class="text-sm text-gray-500">今日支出</p>
<p class="text-2xl font-bold text-red-600">¥{{ todayTotal.toLocaleString() }}</p>
</div>
<div class="text-center p-4 bg-blue-50 rounded-lg">
<p class="text-sm text-gray-500">记录笔数</p>
<p class="text-2xl font-bold text-blue-600">{{ todayExpenses.length }}</p>
</div>
<div class="text-center p-4 bg-green-50 rounded-lg">
<p class="text-sm text-gray-500">主要类别</p>
<p class="text-2xl font-bold text-green-600">{{ topCategory || '-' }}</p>
</div>
</div>
<!-- 今日费用列表 -->
<div class="space-y-3">
<div v-for="expense in todayExpenses" :key="expense.id"
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div class="flex items-center space-x-3">
<span class="text-2xl">{{ expense.emoji }}</span>
<div>
<p class="font-medium">{{ expense.merchant || '未知商家' }}</p>
<p class="text-sm text-gray-500">{{ expense.time }} · {{ expense.method }}</p>
</div>
</div>
<div class="text-right">
<p class="font-bold text-red-600">¥{{ expense.amount.toLocaleString() }}</p>
<Tag size="small" :color="getCategoryColor(expense.category)">{{ expense.category }}</Tag>
</div>
</div>
</div>
</div>
</Card>
<!-- 费用分析 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<Card title="📊 本周费用趋势">
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
<div class="text-center">
<div class="text-4xl mb-2">📈</div>
<p class="text-gray-600">费用趋势分析</p>
<p class="text-sm text-gray-500">每日费用变化图表</p>
</div>
</div>
</Card>
<Card title="🏪 商家排行">
<div v-if="merchantRanking.length === 0" class="text-center py-8">
<div class="text-4xl mb-3">🏪</div>
<p class="text-gray-500">暂无商家数据</p>
</div>
<div v-else class="space-y-3">
<div v-for="(merchant, index) in merchantRanking" :key="merchant.name"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center space-x-3">
<span class="text-lg font-bold text-gray-400">{{ index + 1 }}</span>
<span class="font-medium">{{ merchant.name }}</span>
</div>
<div class="text-right">
<p class="font-semibold">¥{{ merchant.total.toLocaleString() }}</p>
<p class="text-xs text-gray-500">{{ merchant.count }}</p>
</div>
</div>
</div>
</Card>
</div>
<!-- 智能分析 -->
<Card class="mb-6" title="🧠 智能分析">
<div v-if="insights.length === 0" class="text-center py-8">
<div class="text-4xl mb-3">🤖</div>
<p class="text-gray-500">积累更多数据后将为您提供智能分析</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="insight in insights" :key="insight.id" class="p-4 border border-gray-200 rounded-lg">
<div class="flex items-start space-x-3">
<span class="text-2xl">{{ insight.emoji }}</span>
<div>
<h4 class="font-medium mb-1">{{ insight.title }}</h4>
<p class="text-sm text-gray-600 mb-2">{{ insight.description }}</p>
<Tag :color="insight.type === 'warning' ? 'orange' : insight.type === 'tip' ? 'blue' : 'green'">
{{ insight.type === 'warning' ? '注意' : insight.type === 'tip' ? '建议' : '良好' }}
</Tag>
</div>
</div>
</div>
</div>
</Card>
<!-- 快速添加模态框 -->
<Modal v-model:open="showQuickAdd" title="✍️ 快速记录费用">
<Form :model="quickExpenseForm" layout="vertical">
<Row :gutter="16">
<Col :span="12">
<Form.Item label="金额" required>
<InputNumber v-model:value="quickExpenseForm.amount" :precision="2" style="width: 100%" placeholder="0.00" size="large" />
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="支付方式">
<Select v-model:value="quickExpenseForm.method">
<Select.Option value="cash">现金</Select.Option>
<Select.Option value="card">刷卡</Select.Option>
<Select.Option value="mobile">手机支付</Select.Option>
<Select.Option value="online">网上支付</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row :gutter="16">
<Col :span="12">
<Form.Item label="消费类别">
<Select v-model:value="quickExpenseForm.category" placeholder="选择或搜索类别" show-search>
<Select.Option value="food">餐饮</Select.Option>
<Select.Option value="transport">交通</Select.Option>
<Select.Option value="shopping">购物</Select.Option>
<Select.Option value="entertainment">娱乐</Select.Option>
<Select.Option value="medical">医疗</Select.Option>
<Select.Option value="education">教育</Select.Option>
</Select>
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="商家名称">
<AutoComplete v-model:value="quickExpenseForm.merchant" :options="merchantSuggestions" placeholder="输入商家名称" />
</Form.Item>
</Col>
</Row>
<Form.Item label="消费描述">
<Input.TextArea v-model:value="quickExpenseForm.description" :rows="2" placeholder="简单描述这笔消费..." />
</Form.Item>
<Form.Item label="添加标签">
<Select v-model:value="quickExpenseForm.tags" mode="tags" placeholder="添加标签便于分类">
<Select.Option value="必需品">必需品</Select.Option>
<Select.Option value="一次性">一次性</Select.Option>
<Select.Option value="定期">定期</Select.Option>
</Select>
</Form.Item>
<Form.Item label="是否分期">
<div class="flex items-center space-x-4">
<Switch v-model:checked="quickExpenseForm.isInstallment" />
<span class="text-sm text-gray-500">如果是信用卡分期消费请开启</span>
</div>
<div v-if="quickExpenseForm.isInstallment" class="mt-3 grid grid-cols-2 gap-4">
<Input placeholder="分期期数" />
<InputNumber placeholder="每期金额" style="width: 100%" />
</div>
</Form.Item>
</Form>
<template #footer>
<div class="flex justify-between">
<Button @click="showQuickAdd = false">取消</Button>
<Space>
<Button @click="saveAndContinue">保存并继续</Button>
<Button type="primary" @click="saveQuickExpense">保存</Button>
</Space>
</div>
</template>
</Modal>
<!-- 相机拍摄模态框 -->
<Modal v-model:open="showCamera" title="📷 拍摄小票" width="400px">
<div class="text-center py-8">
<div class="mb-4">
<video ref="videoRef" autoplay muted style="width: 100%; max-width: 300px; border-radius: 8px;"></video>
</div>
<canvas ref="canvasRef" style="display: none;"></canvas>
<div class="space-x-4">
<Button type="primary" @click="capturePhoto">📸 拍照</Button>
<Button @click="stopCamera">取消</Button>
</div>
<p class="text-xs text-gray-500 mt-2">请将小票置于画面中心</p>
</div>
</Modal>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { computed, ref } from 'vue';
import { import {
Card, Button, Table, Tag, Modal, Form, Row, Col, InputNumber, AutoComplete,
Select, AutoComplete, Input, Switch, Space Button,
Card,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Switch,
Tag,
} from 'ant-design-vue'; } from 'ant-design-vue';
defineOptions({ name: 'ExpenseTracking' }); defineOptions({ name: 'ExpenseTracking' });
@@ -250,16 +38,19 @@ const merchantSuggestions = ref([]);
// 计算属性 // 计算属性
const todayTotal = computed(() => const todayTotal = computed(() =>
todayExpenses.value.reduce((sum, expense) => sum + expense.amount, 0) todayExpenses.value.reduce((sum, expense) => sum + expense.amount, 0),
); );
const topCategory = computed(() => { const topCategory = computed(() => {
if (todayExpenses.value.length === 0) return null; if (todayExpenses.value.length === 0) return null;
const categoryCount = {}; const categoryCount = {};
todayExpenses.value.forEach(expense => { todayExpenses.value.forEach((expense) => {
categoryCount[expense.category] = (categoryCount[expense.category] || 0) + 1; categoryCount[expense.category] =
(categoryCount[expense.category] || 0) + 1;
}); });
return Object.keys(categoryCount).reduce((a, b) => categoryCount[a] > categoryCount[b] ? a : b); return Object.keys(categoryCount).reduce((a, b) =>
categoryCount[a] > categoryCount[b] ? a : b,
);
}); });
// 快速费用表单 // 快速费用表单
@@ -270,14 +61,18 @@ const quickExpenseForm = ref({
merchant: '', merchant: '',
description: '', description: '',
tags: [], tags: [],
isInstallment: false isInstallment: false,
}); });
// 方法实现 // 方法实现
const getCategoryColor = (category: string) => { const getCategoryColor = (category: string) => {
const colorMap = { const colorMap = {
'food': 'orange', 'transport': 'blue', 'shopping': 'purple', food: 'orange',
'entertainment': 'pink', 'medical': 'red', 'education': 'green' transport: 'blue',
shopping: 'purple',
entertainment: 'pink',
medical: 'red',
education: 'green',
}; };
return colorMap[category] || 'default'; return colorMap[category] || 'default';
}; };
@@ -314,7 +109,7 @@ const capturePhoto = () => {
const stopCamera = () => { const stopCamera = () => {
const video = videoRef.value; const video = videoRef.value;
if (video.srcObject) { if (video.srcObject) {
video.srcObject.getTracks().forEach(track => track.stop()); video.srcObject.getTracks().forEach((track) => track.stop());
} }
showCamera.value = false; showCamera.value = false;
}; };
@@ -355,11 +150,322 @@ const resetQuickForm = () => {
merchant: '', merchant: '',
description: '', description: '',
tags: [], tags: [],
isInstallment: false isInstallment: false,
}; };
}; };
</script> </script>
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="mb-2 text-3xl font-bold text-gray-900">📱 费用追踪</h1>
<p class="text-gray-600">智能费用追踪支持小票OCR识别和自动分类</p>
</div>
<!-- 快速添加费用 -->
<Card class="mb-6" title="⚡ 快速记录">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<!-- 拍照记录 -->
<div
class="cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center hover:border-blue-400"
@click="openCamera"
>
<div class="mb-3 text-4xl">📷</div>
<h3 class="mb-2 font-medium">拍照记录</h3>
<p class="text-sm text-gray-500">拍摄小票自动识别金额和商家</p>
</div>
<!-- 语音记录 -->
<div
class="cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center hover:border-green-400"
@click="startVoiceRecord"
>
<div class="mb-3 text-4xl">🎤</div>
<h3 class="mb-2 font-medium">语音记录</h3>
<p class="text-sm text-gray-500">说出消费内容智能转换为记录</p>
</div>
<!-- 手动输入 -->
<div
class="cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center hover:border-purple-400"
@click="showQuickAdd = true"
>
<div class="mb-3 text-4xl"></div>
<h3 class="mb-2 font-medium">手动输入</h3>
<p class="text-sm text-gray-500">快速手动输入费用信息</p>
</div>
</div>
</Card>
<!-- 今日费用汇总 -->
<Card class="mb-6" title="📅 今日费用汇总">
<div v-if="todayExpenses.length === 0" class="py-8 text-center">
<div class="mb-4 text-6xl">💸</div>
<p class="mb-4 text-gray-500">今天还没有费用记录</p>
<Button type="primary" @click="openCamera">开始记录第一笔费用</Button>
</div>
<div v-else>
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-lg bg-red-50 p-4 text-center">
<p class="text-sm text-gray-500">今日支出</p>
<p class="text-2xl font-bold text-red-600">
${{ todayTotal.toLocaleString() }}
</p>
</div>
<div class="rounded-lg bg-blue-50 p-4 text-center">
<p class="text-sm text-gray-500">记录笔数</p>
<p class="text-2xl font-bold text-blue-600">
{{ todayExpenses.length }}
</p>
</div>
<div class="rounded-lg bg-green-50 p-4 text-center">
<p class="text-sm text-gray-500">主要类别</p>
<p class="text-2xl font-bold text-green-600">
{{ topCategory || '-' }}
</p>
</div>
</div>
<!-- 今日费用列表 -->
<div class="space-y-3">
<div
v-for="expense in todayExpenses"
:key="expense.id"
class="flex items-center justify-between rounded-lg bg-gray-50 p-4"
>
<div class="flex items-center space-x-3">
<span class="text-2xl">{{ expense.emoji }}</span>
<div>
<p class="font-medium">{{ expense.merchant || '未知商家' }}</p>
<p class="text-sm text-gray-500">
{{ expense.time }} · {{ expense.method }}
</p>
</div>
</div>
<div class="text-right">
<p class="font-bold text-red-600">
${{ expense.amount.toLocaleString() }}
</p>
<Tag size="small" :color="getCategoryColor(expense.category)">
{{ expense.category }}
</Tag>
</div>
</div>
</div>
</div>
</Card>
<!-- 费用分析 -->
<div class="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card title="📊 本周费用趋势">
<div
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
>
<div class="text-center">
<div class="mb-2 text-4xl">📈</div>
<p class="text-gray-600">费用趋势分析</p>
<p class="text-sm text-gray-500">每日费用变化图表</p>
</div>
</div>
</Card>
<Card title="🏪 商家排行">
<div v-if="merchantRanking.length === 0" class="py-8 text-center">
<div class="mb-3 text-4xl">🏪</div>
<p class="text-gray-500">暂无商家数据</p>
</div>
<div v-else class="space-y-3">
<div
v-for="(merchant, index) in merchantRanking"
:key="merchant.name"
class="flex items-center justify-between rounded-lg bg-gray-50 p-3"
>
<div class="flex items-center space-x-3">
<span class="text-lg font-bold text-gray-400">{{
index + 1
}}</span>
<span class="font-medium">{{ merchant.name }}</span>
</div>
<div class="text-right">
<p class="font-semibold">
${{ merchant.total.toLocaleString() }}
</p>
<p class="text-xs text-gray-500">{{ merchant.count }}</p>
</div>
</div>
</div>
</Card>
</div>
<!-- 智能分析 -->
<Card class="mb-6" title="🧠 智能分析">
<div v-if="insights.length === 0" class="py-8 text-center">
<div class="mb-3 text-4xl">🤖</div>
<p class="text-gray-500">积累更多数据后将为您提供智能分析</p>
</div>
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="insight in insights"
:key="insight.id"
class="rounded-lg border border-gray-200 p-4"
>
<div class="flex items-start space-x-3">
<span class="text-2xl">{{ insight.emoji }}</span>
<div>
<h4 class="mb-1 font-medium">{{ insight.title }}</h4>
<p class="mb-2 text-sm text-gray-600">
{{ insight.description }}
</p>
<Tag
:color="
insight.type === 'warning'
? 'orange'
: insight.type === 'tip'
? 'blue'
: 'green'
"
>
{{
insight.type === 'warning'
? '注意'
: insight.type === 'tip'
? '建议'
: '良好'
}}
</Tag>
</div>
</div>
</div>
</div>
</Card>
<!-- 快速添加模态框 -->
<Modal v-model:open="showQuickAdd" title="✍️ 快速记录费用">
<Form :model="quickExpenseForm" layout="vertical">
<Row :gutter="16">
<Col :span="12">
<Form.Item label="金额" required>
<InputNumber
v-model:value="quickExpenseForm.amount"
:precision="2"
style="width: 100%"
placeholder="0.00"
size="large"
/>
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="支付方式">
<Select v-model:value="quickExpenseForm.method">
<Select.Option value="cash">现金</Select.Option>
<Select.Option value="card">刷卡</Select.Option>
<Select.Option value="mobile">手机支付</Select.Option>
<Select.Option value="online">网上支付</Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Row :gutter="16">
<Col :span="12">
<Form.Item label="消费类别">
<Select
v-model:value="quickExpenseForm.category"
placeholder="选择或搜索类别"
show-search
>
<Select.Option value="food">餐饮</Select.Option>
<Select.Option value="transport">交通</Select.Option>
<Select.Option value="shopping">购物</Select.Option>
<Select.Option value="entertainment">娱乐</Select.Option>
<Select.Option value="medical">医疗</Select.Option>
<Select.Option value="education">教育</Select.Option>
</Select>
</Form.Item>
</Col>
<Col :span="12">
<Form.Item label="商家名称">
<AutoComplete
v-model:value="quickExpenseForm.merchant"
:options="merchantSuggestions"
placeholder="输入商家名称"
/>
</Form.Item>
</Col>
</Row>
<Form.Item label="消费描述">
<Input.TextArea
v-model:value="quickExpenseForm.description"
:rows="2"
placeholder="简单描述这笔消费..."
/>
</Form.Item>
<Form.Item label="添加标签">
<Select
v-model:value="quickExpenseForm.tags"
mode="tags"
placeholder="添加标签便于分类"
>
<Select.Option value="必需品">必需品</Select.Option>
<Select.Option value="一次性">一次性</Select.Option>
<Select.Option value="定期">定期</Select.Option>
</Select>
</Form.Item>
<Form.Item label="是否分期">
<div class="flex items-center space-x-4">
<Switch v-model:checked="quickExpenseForm.isInstallment" />
<span class="text-sm text-gray-500"
>如果是信用卡分期消费请开启</span
>
</div>
<div
v-if="quickExpenseForm.isInstallment"
class="mt-3 grid grid-cols-2 gap-4"
>
<Input placeholder="分期期数" />
<InputNumber placeholder="每期金额" style="width: 100%" />
</div>
</Form.Item>
</Form>
<template #footer>
<div class="flex justify-between">
<Button @click="showQuickAdd = false">取消</Button>
<Space>
<Button @click="saveAndContinue">保存并继续</Button>
<Button type="primary" @click="saveQuickExpense">保存</Button>
</Space>
</div>
</template>
</Modal>
<!-- 相机拍摄模态框 -->
<Modal v-model:open="showCamera" title="📷 拍摄小票" width="400px">
<div class="py-8 text-center">
<div class="mb-4">
<video
ref="videoRef"
autoplay
muted
style="width: 100%; max-width: 300px; border-radius: 8px"
></video>
</div>
<canvas ref="canvasRef" style="display: none"></canvas>
<div class="space-x-4">
<Button type="primary" @click="capturePhoto">📸 拍照</Button>
<Button @click="stopCamera">取消</Button>
</div>
<p class="mt-2 text-xs text-gray-500">请将小票置于画面中心</p>
</div>
</Modal>
</div>
</template>
<style scoped> <style scoped>
.grid { display: grid; } .grid {
display: grid;
}
</style> </style>

View File

@@ -1,38 +1,183 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
AutoComplete,
Button,
Card,
Col,
DatePicker,
Form,
Input,
Modal,
RangePicker,
Row,
Select,
Space,
Table,
Tag,
Upload,
} from 'ant-design-vue';
import dayjs from 'dayjs';
defineOptions({ name: 'InvoiceManagement' });
const showOcrUpload = ref(false);
const showCreateInvoice = ref(false);
const ocrResult = ref(null);
// 发票统计(无虚拟数据)
const invoiceStats = ref({
pending: 0,
issued: 0,
received: 0,
totalAmount: 0,
});
// 发票列表(空数据)
const invoices = ref([]);
// 发票表格列
const invoiceColumns = [
{
title: '发票号码',
dataIndex: 'invoiceNumber',
key: 'invoiceNumber',
width: 150,
},
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
{ title: '客户/供应商', dataIndex: 'customer', key: 'customer' },
{ title: '开票日期', dataIndex: 'issueDate', key: 'issueDate', width: 120 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 120 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 200 },
];
// 发票明细表格列
const invoiceItemColumns = [
{ title: '项目名称', dataIndex: 'name', key: 'name' },
{ title: '规格型号', dataIndex: 'specification', key: 'specification' },
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 100 },
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice', width: 100 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 100 },
{ title: '操作', key: 'action', width: 80 },
];
// 客户选项(空数据)
const customerOptions = ref([]);
// 发票表单
const invoiceForm = ref({
type: 'sales',
code: '',
customer: '',
issueDate: dayjs(),
taxRate: 13,
items: [],
notes: '',
});
// 方法实现
const getInvoiceStatusColor = (status: string) => {
const statusMap = { pending: 'orange', issued: 'green', cancelled: 'red' };
return statusMap[status] || 'default';
};
const getInvoiceStatusText = (status: string) => {
const textMap = { pending: '待开具', issued: '已开具', cancelled: '已作废' };
return textMap[status] || status;
};
const calculateTotal = () => {
return invoiceForm.value.items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0,
);
};
const calculateTax = () => {
return calculateTotal() * (invoiceForm.value.taxRate / 100);
};
const handleOcrUpload = (info) => {
console.log('OCR上传处理:', info);
// 模拟OCR识别结果
setTimeout(() => {
ocrResult.value = {
invoiceNumber: `INV${Date.now()}`,
issueDate: dayjs().format('YYYY-MM-DD'),
seller: '示例公司',
buyer: '客户公司',
amount: '1000.00',
tax: '130.00',
};
}, 2000);
};
const saveOcrInvoice = () => {
console.log('保存OCR识别的发票:', ocrResult.value);
showOcrUpload.value = false;
ocrResult.value = null;
};
const addInvoiceItem = () => {
invoiceForm.value.items.push({
name: '',
specification: '',
quantity: 1,
unitPrice: 0,
amount: 0,
});
};
const batchImport = () => {
console.log('批量导入发票');
};
</script>
<template> <template>
<div class="p-6"> <div class="p-6">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">📄 发票管理</h1> <h1 class="mb-2 text-3xl font-bold text-gray-900">📄 发票管理</h1>
<p class="text-gray-600">管理进项发票销项发票支持OCR识别和自动记账</p> <p class="text-gray-600">管理进项发票销项发票支持OCR识别和自动记账</p>
</div> </div>
<!-- 发票统计卡片 --> <!-- 发票统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
<Card class="text-center hover:shadow-lg transition-shadow"> <Card class="text-center transition-shadow hover:shadow-lg">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl">📤</div> <div class="text-3xl">📤</div>
<p class="text-sm text-gray-500">待开发票</p> <p class="text-sm text-gray-500">待开发票</p>
<p class="text-2xl font-bold text-orange-600">{{ invoiceStats.pending }}</p> <p class="text-2xl font-bold text-orange-600">
{{ invoiceStats.pending }}
</p>
</div> </div>
</Card> </Card>
<Card class="text-center hover:shadow-lg transition-shadow"> <Card class="text-center transition-shadow hover:shadow-lg">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl"></div> <div class="text-3xl"></div>
<p class="text-sm text-gray-500">已开发票</p> <p class="text-sm text-gray-500">已开发票</p>
<p class="text-2xl font-bold text-green-600">{{ invoiceStats.issued }}</p> <p class="text-2xl font-bold text-green-600">
{{ invoiceStats.issued }}
</p>
</div> </div>
</Card> </Card>
<Card class="text-center hover:shadow-lg transition-shadow"> <Card class="text-center transition-shadow hover:shadow-lg">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl">📥</div> <div class="text-3xl">📥</div>
<p class="text-sm text-gray-500">收到发票</p> <p class="text-sm text-gray-500">收到发票</p>
<p class="text-2xl font-bold text-blue-600">{{ invoiceStats.received }}</p> <p class="text-2xl font-bold text-blue-600">
{{ invoiceStats.received }}
</p>
</div> </div>
</Card> </Card>
<Card class="text-center hover:shadow-lg transition-shadow"> <Card class="text-center transition-shadow hover:shadow-lg">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl">💰</div> <div class="text-3xl">💰</div>
<p class="text-sm text-gray-500">发票金额</p> <p class="text-sm text-gray-500">发票金额</p>
<p class="text-2xl font-bold text-purple-600">¥{{ invoiceStats.totalAmount.toLocaleString() }}</p> <p class="text-2xl font-bold text-purple-600">
${{ invoiceStats.totalAmount.toLocaleString() }}
</p>
</div> </div>
</Card> </Card>
</div> </div>
@@ -59,22 +204,18 @@
<Button type="primary" @click="showCreateInvoice = true"> <Button type="primary" @click="showCreateInvoice = true">
📝 开具发票 📝 开具发票
</Button> </Button>
<Button @click="showOcrUpload = true"> <Button @click="showOcrUpload = true"> 📷 OCR识别 </Button>
📷 OCR识别 <Button @click="batchImport"> 📥 批量导入 </Button>
</Button>
<Button @click="batchImport">
📥 批量导入
</Button>
</div> </div>
</div> </div>
</Card> </Card>
<!-- 发票列表 --> <!-- 发票列表 -->
<Card title="📋 发票清单"> <Card title="📋 发票清单">
<div v-if="invoices.length === 0" class="text-center py-12"> <div v-if="invoices.length === 0" class="py-12 text-center">
<div class="text-8xl mb-6">📄</div> <div class="mb-6 text-8xl">📄</div>
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无发票记录</h3> <h3 class="mb-2 text-xl font-medium text-gray-800">暂无发票记录</h3>
<p class="text-gray-500 mb-6">开始管理您的发票支持OCR自动识别</p> <p class="mb-6 text-gray-500">开始管理您的发票支持OCR自动识别</p>
<div class="space-x-4"> <div class="space-x-4">
<Button type="primary" size="large" @click="showCreateInvoice = true"> <Button type="primary" size="large" @click="showCreateInvoice = true">
📝 开具发票 📝 开具发票
@@ -84,11 +225,16 @@
</Button> </Button>
</div> </div>
</div> </div>
<Table v-else :columns="invoiceColumns" :dataSource="invoices" :pagination="{ pageSize: 10 }"> <Table
v-else
:columns="invoiceColumns"
:data-source="invoices"
:pagination="{ pageSize: 10 }"
>
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'amount'"> <template v-if="column.dataIndex === 'amount'">
<span class="font-semibold text-blue-600"> <span class="font-semibold text-blue-600">
¥{{ record.amount.toLocaleString() }} ${{ record.amount.toLocaleString() }}
</span> </span>
</template> </template>
<template v-else-if="column.dataIndex === 'status'"> <template v-else-if="column.dataIndex === 'status'">
@@ -110,32 +256,46 @@
<!-- OCR上传模态框 --> <!-- OCR上传模态框 -->
<Modal v-model:open="showOcrUpload" title="📷 OCR发票识别" width="600px"> <Modal v-model:open="showOcrUpload" title="📷 OCR发票识别" width="600px">
<div class="text-center py-8"> <div class="py-8 text-center">
<Upload <Upload
:customRequest="handleOcrUpload" :custom-request="handleOcrUpload"
accept="image/*,.pdf" accept="image/*,.pdf"
list-type="picture-card" list-type="picture-card"
:show-upload-list="false" :show-upload-list="false"
:multiple="false" :multiple="false"
> >
<div class="p-8"> <div class="p-8">
<div class="text-6xl mb-4">📷</div> <div class="mb-4 text-6xl">📷</div>
<p class="text-lg font-medium mb-2">上传发票图片或PDF</p> <p class="mb-2 text-lg font-medium">上传发票图片或PDF</p>
<p class="text-sm text-gray-500">支持自动OCR识别发票信息</p> <p class="text-sm text-gray-500">支持自动OCR识别发票信息</p>
<p class="text-xs text-gray-400 mt-2">支持格式: JPG, PNG, PDF</p> <p class="mt-2 text-xs text-gray-400">支持格式: JPG, PNG, PDF</p>
</div> </div>
</Upload> </Upload>
</div> </div>
<div v-if="ocrResult" class="mt-6 p-4 bg-green-50 rounded-lg"> <div v-if="ocrResult" class="mt-6 rounded-lg bg-green-50 p-4">
<h4 class="font-medium text-green-800 mb-3">🎉 识别成功</h4> <h4 class="mb-3 font-medium text-green-800">🎉 识别成功</h4>
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div><span class="text-gray-600">发票号码:</span> {{ ocrResult.invoiceNumber }}</div> <div>
<div><span class="text-gray-600">开票日期:</span> {{ ocrResult.issueDate }}</div> <span class="text-gray-600">发票号码:</span>
<div><span class="text-gray-600">销售方:</span> {{ ocrResult.seller }}</div> {{ ocrResult.invoiceNumber }}
<div><span class="text-gray-600">购买方:</span> {{ ocrResult.buyer }}</div> </div>
<div><span class="text-gray-600">金额:</span> ¥{{ ocrResult.amount }}</div> <div>
<div><span class="text-gray-600">税额:</span> ¥{{ ocrResult.tax }}</div> <span class="text-gray-600">开票日期:</span>
{{ ocrResult.issueDate }}
</div>
<div>
<span class="text-gray-600">销售方:</span> {{ ocrResult.seller }}
</div>
<div>
<span class="text-gray-600">购买方:</span> {{ ocrResult.buyer }}
</div>
<div>
<span class="text-gray-600">金额:</span> ${{ ocrResult.amount }}
</div>
<div>
<span class="text-gray-600">税额:</span> ${{ ocrResult.tax }}
</div>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<Button type="primary" @click="saveOcrInvoice">保存到系统</Button> <Button type="primary" @click="saveOcrInvoice">保存到系统</Button>
@@ -175,14 +335,22 @@
</Col> </Col>
<Col :span="12"> <Col :span="12">
<Form.Item label="开票日期" required> <Form.Item label="开票日期" required>
<DatePicker v-model:value="invoiceForm.issueDate" style="width: 100%" /> <DatePicker
v-model:value="invoiceForm.issueDate"
style="width: 100%"
/>
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<!-- 发票项目明细 --> <!-- 发票项目明细 -->
<Form.Item label="发票明细"> <Form.Item label="发票明细">
<Table :columns="invoiceItemColumns" :dataSource="invoiceForm.items" :pagination="false" size="small"> <Table
:columns="invoiceItemColumns"
:data-source="invoiceForm.items"
:pagination="false"
size="small"
>
<template #footer> <template #footer>
<Button type="dashed" block @click="addInvoiceItem"> <Button type="dashed" block @click="addInvoiceItem">
添加明细项 添加明细项
@@ -206,139 +374,33 @@
</Col> </Col>
<Col :span="8"> <Col :span="8">
<Form.Item label="金额合计"> <Form.Item label="金额合计">
<Input :value="`¥${calculateTotal().toLocaleString()}`" disabled /> <Input
:value="`$${calculateTotal().toLocaleString()}`"
disabled
/>
</Form.Item> </Form.Item>
</Col> </Col>
<Col :span="8"> <Col :span="8">
<Form.Item label="税额"> <Form.Item label="税额">
<Input :value="`¥${calculateTax().toLocaleString()}`" disabled /> <Input :value="`$${calculateTax().toLocaleString()}`" disabled />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item label="备注"> <Form.Item label="备注">
<Input.TextArea v-model:value="invoiceForm.notes" :rows="3" placeholder="发票备注信息..." /> <Input.TextArea
v-model:value="invoiceForm.notes"
:rows="3"
placeholder="发票备注信息..."
/>
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
Card, Input, Select, RangePicker, Button, Table, Tag, Space, Modal,
Upload, Form, Row, Col, DatePicker, AutoComplete
} from 'ant-design-vue';
import dayjs from 'dayjs';
defineOptions({ name: 'InvoiceManagement' });
const showOcrUpload = ref(false);
const showCreateInvoice = ref(false);
const ocrResult = ref(null);
// 发票统计(无虚拟数据)
const invoiceStats = ref({
pending: 0,
issued: 0,
received: 0,
totalAmount: 0
});
// 发票列表(空数据)
const invoices = ref([]);
// 发票表格列
const invoiceColumns = [
{ title: '发票号码', dataIndex: 'invoiceNumber', key: 'invoiceNumber', width: 150 },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
{ title: '客户/供应商', dataIndex: 'customer', key: 'customer' },
{ title: '开票日期', dataIndex: 'issueDate', key: 'issueDate', width: 120 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 120 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 200 }
];
// 发票明细表格列
const invoiceItemColumns = [
{ title: '项目名称', dataIndex: 'name', key: 'name' },
{ title: '规格型号', dataIndex: 'specification', key: 'specification' },
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 100 },
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice', width: 100 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 100 },
{ title: '操作', key: 'action', width: 80 }
];
// 客户选项(空数据)
const customerOptions = ref([]);
// 发票表单
const invoiceForm = ref({
type: 'sales',
code: '',
customer: '',
issueDate: dayjs(),
taxRate: 13,
items: [],
notes: ''
});
// 方法实现
const getInvoiceStatusColor = (status: string) => {
const statusMap = { 'pending': 'orange', 'issued': 'green', 'cancelled': 'red' };
return statusMap[status] || 'default';
};
const getInvoiceStatusText = (status: string) => {
const textMap = { 'pending': '待开具', 'issued': '已开具', 'cancelled': '已作废' };
return textMap[status] || status;
};
const calculateTotal = () => {
return invoiceForm.value.items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
};
const calculateTax = () => {
return calculateTotal() * (invoiceForm.value.taxRate / 100);
};
const handleOcrUpload = (info) => {
console.log('OCR上传处理:', info);
// 模拟OCR识别结果
setTimeout(() => {
ocrResult.value = {
invoiceNumber: 'INV' + Date.now(),
issueDate: dayjs().format('YYYY-MM-DD'),
seller: '示例公司',
buyer: '客户公司',
amount: '1000.00',
tax: '130.00'
};
}, 2000);
};
const saveOcrInvoice = () => {
console.log('保存OCR识别的发票:', ocrResult.value);
showOcrUpload.value = false;
ocrResult.value = null;
};
const addInvoiceItem = () => {
invoiceForm.value.items.push({
name: '',
specification: '',
quantity: 1,
unitPrice: 0,
amount: 0
});
};
const batchImport = () => {
console.log('批量导入发票');
};
</script>
<style scoped> <style scoped>
.grid { display: grid; } .grid {
display: grid;
}
</style> </style>

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { Button, Card, Space, Table, Tag, Select, Tooltip, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useFinanceStore } from '#/store/finance';
defineOptions({ name: 'FinanceMediaCenter' });
const financeStore = useFinanceStore();
const DEFAULT_LIMIT = 200;
const selectedTypes = ref<string[]>([]);
const isRefreshing = ref(false);
const typeOptions = [
{ label: '图片', value: 'photo' },
{ label: '视频', value: 'video' },
{ label: '音频', value: 'audio' },
{ label: '语音', value: 'voice' },
{ label: '文件', value: 'document' },
{ label: '视频消息', value: 'video_note' },
{ label: '动图', value: 'animation' },
{ label: '贴纸', value: 'sticker' },
];
const columns = [
{ title: '类型', dataIndex: 'fileType', key: 'fileType', width: 120 },
{ title: '说明', dataIndex: 'caption', key: 'caption', ellipsis: true },
{ title: '发送者', dataIndex: 'displayName', key: 'sender', width: 160 },
{ title: '用户名', dataIndex: 'username', key: 'username', width: 160 },
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 200 },
{ title: '大小', dataIndex: 'fileSize', key: 'fileSize', width: 120 },
{ title: '状态', dataIndex: 'available', key: 'available', width: 120 },
{ title: '操作', key: 'action', width: 160, fixed: 'right' },
];
const pagination = ref({
current: 1,
pageSize: 10,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total: number) => `${total} 条媒体记录`,
});
const loading = computed(
() => financeStore.loading.mediaMessages || isRefreshing.value,
);
const mediaMessages = computed(() => {
const types = selectedTypes.value;
if (!types || types.length === 0) {
return financeStore.mediaMessages;
}
return financeStore.mediaMessages.filter((item) =>
types.includes(item.fileType),
);
});
const typeLabelMap: Record<string, string> = {
photo: '图片',
video: '视频',
audio: '音频',
voice: '语音',
document: '文件',
video_note: '视频消息',
animation: '动图',
sticker: '贴纸',
};
function formatFileSize(size?: number) {
if (!size || size <= 0) {
return '-';
}
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
async function fetchMedia() {
isRefreshing.value = true;
try {
await financeStore.fetchMediaMessages({
limit: DEFAULT_LIMIT,
fileTypes: selectedTypes.value.length > 0 ? selectedTypes.value : undefined,
});
} catch (error) {
console.error('[finance][media] fetch failed', error);
message.error('媒体记录加载失败,请稍后重试');
} finally {
isRefreshing.value = false;
}
}
function handleDownload(record: (typeof mediaMessages.value)[number]) {
if (!record.available || !record.downloadUrl) {
message.warning('文件不可用或已被移除');
return;
}
const apiBase = (import.meta.env.VITE_GLOB_API_URL ?? '').replace(/\/$/, '');
const url = record.downloadUrl.startsWith('http')
? record.downloadUrl
: `${apiBase}${record.downloadUrl.startsWith('/') ? '' : '/'}${record.downloadUrl}`;
window.open(url, '_blank');
}
onMounted(() => {
fetchMedia();
});
watch(selectedTypes, () => {
// 每次筛选更新时刷新数据
fetchMedia();
});
</script>
<template>
<div class="space-y-4">
<Card
:loading="loading"
title="📂 多媒体中心"
bordered
>
<template #extra>
<Space :size="12">
<Select
v-model:value="selectedTypes"
mode="multiple"
:options="typeOptions"
allow-clear
placeholder="筛选媒体类型"
style="min-width: 240px"
max-tag-count="responsive"
/>
<Button type="primary" :loading="isRefreshing" @click="fetchMedia">
手动刷新
</Button>
</Space>
</template>
<Table
:columns="columns"
:data-source="mediaMessages"
:loading="loading"
:pagination="pagination"
:scroll="{ x: 1000 }"
row-key="id"
bordered
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fileType'">
<Tag color="blue">
{{ typeLabelMap[record.fileType] ?? record.fileType }}
</Tag>
</template>
<template v-else-if="column.key === 'caption'">
<Tooltip>
<template #title>
<div class="max-w-72 whitespace-pre-wrap">
{{ record.caption || '(无备注)' }}
</div>
</template>
<span class="block max-w-56 truncate">
{{ record.caption || '(无备注)' }}
</span>
</Tooltip>
</template>
<template v-else-if="column.key === 'sender'">
{{ record.displayName || `用户 ${record.userId}` }}
</template>
<template v-else-if="column.key === 'createdAt'">
{{ dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') }}
</template>
<template v-else-if="column.key === 'fileSize'">
{{ formatFileSize(record.fileSize) }}
</template>
<template v-else-if="column.key === 'available'">
<Tag :color="record.available ? 'success' : 'error'">
{{ record.available ? '可下载' : '已缺失' }}
</Tag>
</template>
<template v-else-if="column.key === 'action'">
<Space>
<Button
type="link"
size="small"
:disabled="!record.available"
@click="handleDownload(record)"
>
下载
</Button>
<Tooltip title="复制文件路径">
<Button
type="link"
size="small"
@click="
navigator.clipboard
.writeText(record.filePath)
.then(() => message.success('已复制文件路径'))
.catch(() => message.error('复制失败'))
"
>
复制路径
</Button>
</Tooltip>
</Space>
</template>
<template v-else>
{{ record[column.dataIndex as keyof typeof record] ?? '-' }}
</template>
</template>
</Table>
</Card>
</div>
</template>
<style scoped>
.space-y-4 > * + * {
margin-top: 1rem;
}
</style>

View File

@@ -1,7 +1,208 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
Button,
Card,
Col,
DatePicker,
Form,
InputNumber,
Radio,
Row,
Select,
Steps,
Tag,
Timeline,
} from 'ant-design-vue';
defineOptions({ name: 'FinancialPlanning' });
const currentStep = ref(0);
const planningResult = ref(null);
// 规划数据
const planningData = ref({
monthlyIncome: null,
monthlyExpense: null,
cashAssets: null,
investmentAssets: null,
totalDebt: null,
goals: [],
riskAnswers: [],
});
// 风险评估问题
const riskQuestions = ref([
{
title: '如果您的投资在短期内出现20%的亏损,您会如何反应?',
options: [
'立即卖出,避免更大损失',
'保持观望,等待市场恢复',
'继续持有,甚至考虑加仓',
'完全不担心,长期投资',
],
},
{
title: '您更偏好哪种投资方式?',
options: [
'银行定期存款,安全稳定',
'货币基金,流动性好',
'混合型基金,平衡风险收益',
'股票投资,追求高回报',
],
},
{
title: '您的投资经验如何?',
options: [
'完全没有经验',
'了解基本概念',
'有一定实践经验',
'经验丰富,熟悉各种产品',
],
},
]);
// 资产配置建议(空数据,根据评估生成)
const assetAllocation = ref([]);
// 执行计划(空数据)
const executionPlan = ref([]);
// 方法实现
const nextStep = () => {
if (currentStep.value < 3) {
currentStep.value++;
}
};
const prevStep = () => {
if (currentStep.value > 0) {
currentStep.value--;
}
};
const addGoal = () => {
planningData.value.goals.push({
name: '',
amount: null,
deadline: null,
priority: 'medium',
type: 'other',
});
};
const removeGoal = (index: number) => {
planningData.value.goals.splice(index, 1);
};
const generatePlan = () => {
console.log('生成规划方案:', planningData.value);
// 这里实现规划算法
setTimeout(() => {
planningResult.value = {
riskLevel: 'moderate',
recommendations: [],
};
// 根据风险评估生成资产配置
assetAllocation.value = [
{
type: 'cash',
name: '现金类',
percentage: 20,
color: 'text-blue-600',
description: '货币基金',
},
{
type: 'bond',
name: '债券类',
percentage: 30,
color: 'text-green-600',
description: '债券基金',
},
{
type: 'stock',
name: '股票类',
percentage: 40,
color: 'text-red-600',
description: '股票基金',
},
{
type: 'alternative',
name: '另类投资',
percentage: 10,
color: 'text-purple-600',
description: 'REITs等',
},
];
// 生成执行计划
executionPlan.value = [
{
title: '建立紧急基金',
description: '准备3-6个月的生活费作为紧急基金',
timeline: '1-2个月',
color: 'red',
priority: 'high',
},
{
title: '开设投资账户',
description: '选择合适的券商开设证券账户',
timeline: '第3个月',
color: 'blue',
priority: 'normal',
},
{
title: '开始定投计划',
description: '按照资产配置比例开始定期投资',
timeline: '第4个月开始',
color: 'green',
priority: 'normal',
},
];
}, 3000);
};
const getRiskEmoji = () => {
const score = planningData.value.riskAnswers.reduce(
(sum, answer) => sum + (answer || 0),
0,
);
if (score <= 3) return '🛡️';
if (score <= 6) return '⚖️';
return '🚀';
};
const getRiskLevel = () => {
const score = planningData.value.riskAnswers.reduce(
(sum, answer) => sum + (answer || 0),
0,
);
if (score <= 3) return '保守型投资者';
if (score <= 6) return '平衡型投资者';
return '积极型投资者';
};
const getRiskDescription = () => {
const score = planningData.value.riskAnswers.reduce(
(sum, answer) => sum + (answer || 0),
0,
);
if (score <= 3) return '偏好稳健投资,注重本金安全';
if (score <= 6) return '平衡风险与收益,适度投资';
return '愿意承担较高风险,追求高收益';
};
const savePlan = () => {
console.log('保存财务规划:', planningData.value, planningResult.value);
};
</script>
<template> <template>
<div class="p-6"> <div class="p-6">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">🎯 财务规划</h1> <h1 class="mb-2 text-3xl font-bold text-gray-900">🎯 财务规划</h1>
<p class="text-gray-600">智能财务规划向导帮您制定个性化理财计划</p> <p class="text-gray-600">智能财务规划向导帮您制定个性化理财计划</p>
</div> </div>
@@ -16,35 +217,57 @@
<!-- 步骤1: 基本信息 --> <!-- 步骤1: 基本信息 -->
<div v-if="currentStep === 0"> <div v-if="currentStep === 0">
<h3 class="text-lg font-medium mb-4">💼 收入支出信息</h3> <h3 class="mb-4 text-lg font-medium">💼 收入支出信息</h3>
<Row :gutter="16"> <Row :gutter="16">
<Col :span="12"> <Col :span="12">
<Form.Item label="月平均收入"> <Form.Item label="月平均收入">
<InputNumber v-model:value="planningData.monthlyIncome" :precision="0" style="width: 100%" placeholder="请输入月收入" /> <InputNumber
v-model:value="planningData.monthlyIncome"
:precision="0"
style="width: 100%"
placeholder="请输入月收入"
/>
</Form.Item> </Form.Item>
</Col> </Col>
<Col :span="12"> <Col :span="12">
<Form.Item label="月平均支出"> <Form.Item label="月平均支出">
<InputNumber v-model:value="planningData.monthlyExpense" :precision="0" style="width: 100%" placeholder="请输入月支出" /> <InputNumber
v-model:value="planningData.monthlyExpense"
:precision="0"
style="width: 100%"
placeholder="请输入月支出"
/>
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<h3 class="text-lg font-medium mb-4 mt-6">💰 资产负债情况</h3> <h3 class="mb-4 mt-6 text-lg font-medium">💰 资产负债情况</h3>
<Row :gutter="16"> <Row :gutter="16">
<Col :span="8"> <Col :span="8">
<Form.Item label="现金及存款"> <Form.Item label="现金及存款">
<InputNumber v-model:value="planningData.cashAssets" :precision="0" style="width: 100%" /> <InputNumber
v-model:value="planningData.cashAssets"
:precision="0"
style="width: 100%"
/>
</Form.Item> </Form.Item>
</Col> </Col>
<Col :span="8"> <Col :span="8">
<Form.Item label="投资资产"> <Form.Item label="投资资产">
<InputNumber v-model:value="planningData.investmentAssets" :precision="0" style="width: 100%" /> <InputNumber
v-model:value="planningData.investmentAssets"
:precision="0"
style="width: 100%"
/>
</Form.Item> </Form.Item>
</Col> </Col>
<Col :span="8"> <Col :span="8">
<Form.Item label="负债总额"> <Form.Item label="负债总额">
<InputNumber v-model:value="planningData.totalDebt" :precision="0" style="width: 100%" /> <InputNumber
v-model:value="planningData.totalDebt"
:precision="0"
style="width: 100%"
/>
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
@@ -52,9 +275,13 @@
<!-- 步骤2: 目标设定 --> <!-- 步骤2: 目标设定 -->
<div v-if="currentStep === 1"> <div v-if="currentStep === 1">
<h3 class="text-lg font-medium mb-4">🎯 理财目标设置</h3> <h3 class="mb-4 text-lg font-medium">🎯 理财目标设置</h3>
<div class="space-y-6"> <div class="space-y-6">
<div v-for="(goal, index) in planningData.goals" :key="index" class="p-4 border border-gray-200 rounded-lg"> <div
v-for="(goal, index) in planningData.goals"
:key="index"
class="rounded-lg border border-gray-200 p-4"
>
<Row :gutter="16"> <Row :gutter="16">
<Col :span="8"> <Col :span="8">
<Form.Item label="目标名称"> <Form.Item label="目标名称">
@@ -63,17 +290,26 @@
</Col> </Col>
<Col :span="8"> <Col :span="8">
<Form.Item label="目标金额"> <Form.Item label="目标金额">
<InputNumber v-model:value="goal.amount" :precision="0" style="width: 100%" /> <InputNumber
v-model:value="goal.amount"
:precision="0"
style="width: 100%"
/>
</Form.Item> </Form.Item>
</Col> </Col>
<Col :span="6"> <Col :span="6">
<Form.Item label="目标期限"> <Form.Item label="目标期限">
<DatePicker v-model:value="goal.deadline" style="width: 100%" /> <DatePicker
v-model:value="goal.deadline"
style="width: 100%"
/>
</Form.Item> </Form.Item>
</Col> </Col>
<Col :span="2"> <Col :span="2">
<Form.Item label=" "> <Form.Item label=" ">
<Button type="text" danger @click="removeGoal(index)">🗑</Button> <Button type="text" danger @click="removeGoal(index)">
🗑
</Button>
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
@@ -109,13 +345,20 @@
<!-- 步骤3: 风险评估 --> <!-- 步骤3: 风险评估 -->
<div v-if="currentStep === 2"> <div v-if="currentStep === 2">
<h3 class="text-lg font-medium mb-4"> 投资风险评估</h3> <h3 class="mb-4 text-lg font-medium"> 投资风险评估</h3>
<div class="space-y-6"> <div class="space-y-6">
<div v-for="(question, index) in riskQuestions" :key="index" class="p-4 bg-gray-50 rounded-lg"> <div
<h4 class="font-medium mb-3">{{ question.title }}</h4> v-for="(question, index) in riskQuestions"
:key="index"
class="rounded-lg bg-gray-50 p-4"
>
<h4 class="mb-3 font-medium">{{ question.title }}</h4>
<Radio.Group v-model:value="planningData.riskAnswers[index]"> <Radio.Group v-model:value="planningData.riskAnswers[index]">
<div class="space-y-2"> <div class="space-y-2">
<div v-for="(option, optIndex) in question.options" :key="optIndex"> <div
v-for="(option, optIndex) in question.options"
:key="optIndex"
>
<Radio :value="optIndex">{{ option }}</Radio> <Radio :value="optIndex">{{ option }}</Radio>
</div> </div>
</div> </div>
@@ -126,14 +369,16 @@
<!-- 步骤4: 规划方案 --> <!-- 步骤4: 规划方案 -->
<div v-if="currentStep === 3"> <div v-if="currentStep === 3">
<div v-if="!planningResult" class="text-center py-12"> <div v-if="!planningResult" class="py-12 text-center">
<div class="text-6xl mb-4">🤖</div> <div class="mb-4 text-6xl">🤖</div>
<p class="text-gray-500 mb-6">正在为您生成个性化财务规划方案...</p> <p class="mb-6 text-gray-500">正在为您生成个性化财务规划方案...</p>
<Button type="primary" @click="generatePlan" loading>生成规划方案</Button> <Button type="primary" @click="generatePlan" loading>
生成规划方案
</Button>
</div> </div>
<div v-else> <div v-else>
<h3 class="text-lg font-medium mb-4">📋 您的专属财务规划方案</h3> <h3 class="mb-4 text-lg font-medium">📋 您的专属财务规划方案</h3>
<!-- 风险评估结果 --> <!-- 风险评估结果 -->
<Card class="mb-4" title="风险偏好分析"> <Card class="mb-4" title="风险偏好分析">
@@ -148,11 +393,19 @@
<!-- 资产配置建议 --> <!-- 资产配置建议 -->
<Card class="mb-4" title="资产配置建议"> <Card class="mb-4" title="资产配置建议">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4"> <div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div v-for="allocation in assetAllocation" :key="allocation.type" class="text-center p-4 bg-gray-50 rounded-lg"> <div
v-for="allocation in assetAllocation"
:key="allocation.type"
class="rounded-lg bg-gray-50 p-4 text-center"
>
<p class="text-sm text-gray-500">{{ allocation.name }}</p> <p class="text-sm text-gray-500">{{ allocation.name }}</p>
<p class="text-xl font-bold" :class="allocation.color">{{ allocation.percentage }}%</p> <p class="text-xl font-bold" :class="allocation.color">
<p class="text-xs text-gray-400">{{ allocation.description }}</p> {{ allocation.percentage }}%
</p>
<p class="text-xs text-gray-400">
{{ allocation.description }}
</p>
</div> </div>
</div> </div>
</Card> </Card>
@@ -160,15 +413,24 @@
<!-- 具体执行计划 --> <!-- 具体执行计划 -->
<Card title="执行计划"> <Card title="执行计划">
<Timeline> <Timeline>
<Timeline.Item v-for="(step, index) in executionPlan" :key="index" :color="step.color"> <Timeline.Item
v-for="(step, index) in executionPlan"
:key="index"
:color="step.color"
>
<div class="mb-2"> <div class="mb-2">
<span class="font-medium">{{ step.title }}</span> <span class="font-medium">{{ step.title }}</span>
<Tag class="ml-2" :color="step.priority === 'high' ? 'red' : 'blue'"> <Tag
class="ml-2"
:color="step.priority === 'high' ? 'red' : 'blue'"
>
{{ step.priority === 'high' ? '高优先级' : '普通' }} {{ step.priority === 'high' ? '高优先级' : '普通' }}
</Tag> </Tag>
</div> </div>
<p class="text-sm text-gray-600">{{ step.description }}</p> <p class="text-sm text-gray-600">{{ step.description }}</p>
<p class="text-xs text-gray-400 mt-1">预期完成时间: {{ step.timeline }}</p> <p class="mt-1 text-xs text-gray-400">
预期完成时间: {{ step.timeline }}
</p>
</Timeline.Item> </Timeline.Item>
</Timeline> </Timeline>
</Card> </Card>
@@ -176,174 +438,20 @@
</div> </div>
<!-- 导航按钮 --> <!-- 导航按钮 -->
<div class="flex justify-between mt-8"> <div class="mt-8 flex justify-between">
<Button v-if="currentStep > 0" @click="prevStep">上一步</Button> <Button v-if="currentStep > 0" @click="prevStep">上一步</Button>
<div v-else></div> <div v-else></div>
<Button v-if="currentStep < 3" type="primary" @click="nextStep">下一步</Button> <Button v-if="currentStep < 3" type="primary" @click="nextStep">
下一步
</Button>
<Button v-else type="primary" @click="savePlan">保存规划</Button> <Button v-else type="primary" @click="savePlan">保存规划</Button>
</div> </div>
</Card> </Card>
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
Card, Steps, Row, Col, Form, InputNumber, Button, Select,
DatePicker, Radio, Timeline, Tag
} from 'ant-design-vue';
import dayjs from 'dayjs';
defineOptions({ name: 'FinancialPlanning' });
const currentStep = ref(0);
const planningResult = ref(null);
// 规划数据
const planningData = ref({
monthlyIncome: null,
monthlyExpense: null,
cashAssets: null,
investmentAssets: null,
totalDebt: null,
goals: [],
riskAnswers: []
});
// 风险评估问题
const riskQuestions = ref([
{
title: '如果您的投资在短期内出现20%的亏损,您会如何反应?',
options: [
'立即卖出,避免更大损失',
'保持观望,等待市场恢复',
'继续持有,甚至考虑加仓',
'完全不担心,长期投资'
]
},
{
title: '您更偏好哪种投资方式?',
options: [
'银行定期存款,安全稳定',
'货币基金,流动性好',
'混合型基金,平衡风险收益',
'股票投资,追求高回报'
]
},
{
title: '您的投资经验如何?',
options: [
'完全没有经验',
'了解基本概念',
'有一定实践经验',
'经验丰富,熟悉各种产品'
]
}
]);
// 资产配置建议(空数据,根据评估生成)
const assetAllocation = ref([]);
// 执行计划(空数据)
const executionPlan = ref([]);
// 方法实现
const nextStep = () => {
if (currentStep.value < 3) {
currentStep.value++;
}
};
const prevStep = () => {
if (currentStep.value > 0) {
currentStep.value--;
}
};
const addGoal = () => {
planningData.value.goals.push({
name: '',
amount: null,
deadline: null,
priority: 'medium',
type: 'other'
});
};
const removeGoal = (index: number) => {
planningData.value.goals.splice(index, 1);
};
const generatePlan = () => {
console.log('生成规划方案:', planningData.value);
// 这里实现规划算法
setTimeout(() => {
planningResult.value = {
riskLevel: 'moderate',
recommendations: []
};
// 根据风险评估生成资产配置
assetAllocation.value = [
{ type: 'cash', name: '现金类', percentage: 20, color: 'text-blue-600', description: '货币基金' },
{ type: 'bond', name: '债券类', percentage: 30, color: 'text-green-600', description: '债券基金' },
{ type: 'stock', name: '股票类', percentage: 40, color: 'text-red-600', description: '股票基金' },
{ type: 'alternative', name: '另类投资', percentage: 10, color: 'text-purple-600', description: 'REITs等' }
];
// 生成执行计划
executionPlan.value = [
{
title: '建立紧急基金',
description: '准备3-6个月的生活费作为紧急基金',
timeline: '1-2个月',
color: 'red',
priority: 'high'
},
{
title: '开设投资账户',
description: '选择合适的券商开设证券账户',
timeline: '第3个月',
color: 'blue',
priority: 'normal'
},
{
title: '开始定投计划',
description: '按照资产配置比例开始定期投资',
timeline: '第4个月开始',
color: 'green',
priority: 'normal'
}
];
}, 3000);
};
const getRiskEmoji = () => {
const score = planningData.value.riskAnswers.reduce((sum, answer) => sum + (answer || 0), 0);
if (score <= 3) return '🛡️';
if (score <= 6) return '⚖️';
return '🚀';
};
const getRiskLevel = () => {
const score = planningData.value.riskAnswers.reduce((sum, answer) => sum + (answer || 0), 0);
if (score <= 3) return '保守型投资者';
if (score <= 6) return '平衡型投资者';
return '积极型投资者';
};
const getRiskDescription = () => {
const score = planningData.value.riskAnswers.reduce((sum, answer) => sum + (answer || 0), 0);
if (score <= 3) return '偏好稳健投资,注重本金安全';
if (score <= 6) return '平衡风险与收益,适度投资';
return '愿意承担较高风险,追求高收益';
};
const savePlan = () => {
console.log('保存财务规划:', planningData.value, planningResult.value);
};
</script>
<style scoped> <style scoped>
.grid { display: grid; } .grid {
display: grid;
}
</style> </style>

View File

@@ -1,103 +1,7 @@
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">💼 投资组合</h1>
<p class="text-gray-600">实时跟踪投资组合表现智能分析投资收益</p>
</div>
<!-- 组合概览 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl">📊</div>
<p class="text-sm text-gray-500">总市值</p>
<p class="text-2xl font-bold text-blue-600">¥{{ portfolioStats.totalValue.toLocaleString() }}</p>
</div>
</Card>
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl">📈</div>
<p class="text-sm text-gray-500">总收益</p>
<p class="text-2xl font-bold" :class="portfolioStats.totalProfit >= 0 ? 'text-green-600' : 'text-red-600'">
{{ portfolioStats.totalProfit >= 0 ? '+' : '' }}¥{{ portfolioStats.totalProfit.toLocaleString() }}
</p>
</div>
</Card>
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl"></div>
<p class="text-sm text-gray-500">收益率</p>
<p class="text-2xl font-bold" :class="portfolioStats.returnRate >= 0 ? 'text-green-600' : 'text-red-600'">
{{ portfolioStats.returnRate >= 0 ? '+' : '' }}{{ portfolioStats.returnRate.toFixed(2) }}%
</p>
</div>
</Card>
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl">🎯</div>
<p class="text-sm text-gray-500">持仓数量</p>
<p class="text-2xl font-bold text-purple-600">{{ holdings.length }}</p>
</div>
</Card>
</div>
<!-- 持仓列表 -->
<Card title="📋 持仓明细" class="mb-6">
<template #extra>
<Button type="primary" @click="showAddHolding = true"> 添加持仓</Button>
</template>
<div v-if="holdings.length === 0" class="text-center py-12">
<div class="text-8xl mb-6">💼</div>
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无投资持仓</h3>
<p class="text-gray-500 mb-6">开始记录您的投资组合</p>
<Button type="primary" size="large" @click="showAddHolding = true">
添加第一笔投资
</Button>
</div>
<Table v-else :columns="holdingColumns" :dataSource="holdings" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'profit'">
<span :class="record.profit >= 0 ? 'text-green-600 font-semibold' : 'text-red-600 font-semibold'">
{{ record.profit >= 0 ? '+' : '' }}¥{{ record.profit.toLocaleString() }}
</span>
</template>
<template v-else-if="column.dataIndex === 'returnRate'">
<span :class="record.returnRate >= 0 ? 'text-green-600 font-semibold' : 'text-red-600 font-semibold'">
{{ record.returnRate >= 0 ? '+' : '' }}{{ record.returnRate.toFixed(2) }}%
</span>
</template>
</template>
</Table>
</Card>
<!-- 投资分析 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card title="📈 收益走势">
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
<div class="text-center">
<div class="text-4xl mb-2">📊</div>
<p class="text-gray-600">投资收益趋势图</p>
</div>
</div>
</Card>
<Card title="🥧 资产配置">
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
<div class="text-center">
<div class="text-4xl mb-2">🍰</div>
<p class="text-gray-600">资产配置分布图</p>
</div>
</div>
</Card>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref } from 'vue';
import { Card, Button, Table } from 'ant-design-vue';
import { Button, Card, Table } from 'ant-design-vue';
defineOptions({ name: 'InvestmentPortfolio' }); defineOptions({ name: 'InvestmentPortfolio' });
@@ -107,7 +11,7 @@ const showAddHolding = ref(false);
const portfolioStats = ref({ const portfolioStats = ref({
totalValue: 0, totalValue: 0,
totalProfit: 0, totalProfit: 0,
returnRate: 0 returnRate: 0,
}); });
// 持仓列表(空数据) // 持仓列表(空数据)
@@ -120,10 +24,154 @@ const holdingColumns = [
{ title: '成本价', dataIndex: 'costPrice', key: 'costPrice', width: 100 }, { title: '成本价', dataIndex: 'costPrice', key: 'costPrice', width: 100 },
{ title: '现价', dataIndex: 'currentPrice', key: 'currentPrice', width: 100 }, { title: '现价', dataIndex: 'currentPrice', key: 'currentPrice', width: 100 },
{ title: '盈亏', dataIndex: 'profit', key: 'profit', width: 120 }, { title: '盈亏', dataIndex: 'profit', key: 'profit', width: 120 },
{ title: '收益率', dataIndex: 'returnRate', key: 'returnRate', width: 100 } { title: '收益率', dataIndex: 'returnRate', key: 'returnRate', width: 100 },
]; ];
</script> </script>
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="mb-2 text-3xl font-bold text-gray-900">💼 投资组合</h1>
<p class="text-gray-600">实时跟踪投资组合表现智能分析投资收益</p>
</div>
<!-- 组合概览 -->
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl">📊</div>
<p class="text-sm text-gray-500">总市值</p>
<p class="text-2xl font-bold text-blue-600">
${{ portfolioStats.totalValue.toLocaleString() }}
</p>
</div>
</Card>
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl">📈</div>
<p class="text-sm text-gray-500">总收益</p>
<p
class="text-2xl font-bold"
:class="
portfolioStats.totalProfit >= 0
? 'text-green-600'
: 'text-red-600'
"
>
{{ portfolioStats.totalProfit >= 0 ? '+' : '' }}${{
portfolioStats.totalProfit.toLocaleString()
}}
</p>
</div>
</Card>
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl"></div>
<p class="text-sm text-gray-500">收益率</p>
<p
class="text-2xl font-bold"
:class="
portfolioStats.returnRate >= 0 ? 'text-green-600' : 'text-red-600'
"
>
{{ portfolioStats.returnRate >= 0 ? '+' : ''
}}{{ portfolioStats.returnRate.toFixed(2) }}%
</p>
</div>
</Card>
<Card class="text-center">
<div class="space-y-2">
<div class="text-3xl">🎯</div>
<p class="text-sm text-gray-500">持仓数量</p>
<p class="text-2xl font-bold text-purple-600">
{{ holdings.length }}
</p>
</div>
</Card>
</div>
<!-- 持仓列表 -->
<Card title="📋 持仓明细" class="mb-6">
<template #extra>
<Button type="primary" @click="showAddHolding = true">
添加持仓
</Button>
</template>
<div v-if="holdings.length === 0" class="py-12 text-center">
<div class="mb-6 text-8xl">💼</div>
<h3 class="mb-2 text-xl font-medium text-gray-800">暂无投资持仓</h3>
<p class="mb-6 text-gray-500">开始记录您的投资组合</p>
<Button type="primary" size="large" @click="showAddHolding = true">
添加第一笔投资
</Button>
</div>
<Table
v-else
:columns="holdingColumns"
:data-source="holdings"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'profit'">
<span
:class="
record.profit >= 0
? 'font-semibold text-green-600'
: 'font-semibold text-red-600'
"
>
{{ record.profit >= 0 ? '+' : '' }}${{
record.profit.toLocaleString()
}}
</span>
</template>
<template v-else-if="column.dataIndex === 'returnRate'">
<span
:class="
record.returnRate >= 0
? 'font-semibold text-green-600'
: 'font-semibold text-red-600'
"
>
{{ record.returnRate >= 0 ? '+' : ''
}}{{ record.returnRate.toFixed(2) }}%
</span>
</template>
</template>
</Table>
</Card>
<!-- 投资分析 -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card title="📈 收益走势">
<div
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
>
<div class="text-center">
<div class="mb-2 text-4xl">📊</div>
<p class="text-gray-600">投资收益趋势图</p>
</div>
</div>
</Card>
<Card title="🥧 资产配置">
<div
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
>
<div class="text-center">
<div class="mb-2 text-4xl">🍰</div>
<p class="text-gray-600">资产配置分布图</p>
</div>
</div>
</Card>
</div>
</div>
</template>
<style scoped> <style scoped>
.grid { display: grid; } .grid {
display: grid;
}
</style> </style>

View File

@@ -0,0 +1,210 @@
<script setup lang="ts">
import type { TableColumnsType, TableRowSelection } from 'ant-design-vue';
import {
Button,
Card,
Input,
Modal,
Space,
Table,
Tag,
message,
} from 'ant-design-vue';
import { computed, h, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { FinanceApi } from '#/api/core/finance';
import { useFinanceStore } from '#/store/finance';
import { formatStatus } from './status';
defineOptions({ name: 'FinanceReimbursementApproval' });
const financeStore = useFinanceStore();
const router = useRouter();
const selectedRowKeys = ref<number[]>([]);
const loading = computed(() => financeStore.loading.reimbursements);
const pendingStatuses: FinanceApi.TransactionStatus[] = ['draft', 'pending'];
const reimbursements = computed(() =>
financeStore.reimbursements
.filter(
(item) =>
!item.isDeleted && pendingStatuses.includes(item.status),
)
.sort(
(a, b) =>
new Date(a.transactionDate).getTime() -
new Date(b.transactionDate).getTime(),
),
);
const rowSelection = computed<TableRowSelection<FinanceApi.Transaction>>(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys as number[];
},
}));
const columns: TableColumnsType<FinanceApi.Transaction> = [
{
title: '报销事项',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '报销日期',
dataIndex: 'transactionDate',
key: 'transactionDate',
width: 140,
},
{
title: '金额',
key: 'amount',
width: 140,
},
{
title: '提交人',
dataIndex: 'submittedBy',
key: 'submittedBy',
width: 120,
},
{
title: '状态',
key: 'status',
width: 120,
},
{
title: '操作',
key: 'actions',
width: 220,
fixed: 'right',
},
];
async function refresh() {
await financeStore.fetchReimbursements();
}
async function updateStatus(
record: FinanceApi.Transaction,
status: FinanceApi.TransactionStatus,
reviewNotes?: string | null,
) {
await financeStore.updateReimbursement(record.id, {
status,
reviewNotes: reviewNotes ?? record.reviewNotes ?? null,
});
message.success('状态已更新');
}
function handleApprove(record: FinanceApi.Transaction) {
updateStatus(record, 'approved');
}
function handleReject(record: FinanceApi.Transaction) {
let notes = record.reviewNotes ?? '';
Modal.confirm({
title: '驳回报销申请',
content: () =>
h(Input.TextArea, {
value: notes,
rows: 3,
onChange(event: any) {
notes = event.target.value;
},
placeholder: '请输入驳回原因',
}),
okText: '确认驳回',
cancelText: '取消',
okButtonProps: { danger: true },
async onOk() {
await updateStatus(record, 'rejected', notes);
},
});
}
async function handleBulkApprove() {
if (selectedRowKeys.value.length === 0) return;
const list = financeStore.reimbursements.filter((item) =>
selectedRowKeys.value.includes(item.id),
);
await Promise.all(
list.map((item) =>
financeStore.updateReimbursement(item.id, { status: 'approved' }),
),
);
message.success('已批量通过审批');
selectedRowKeys.value = [];
}
function handleView(record: FinanceApi.Transaction) {
router.push(`/reimbursement/detail/${record.id}`);
}
onMounted(() => {
if (financeStore.reimbursements.length === 0) {
refresh();
}
});
</script>
<template>
<div class="p-6 space-y-4">
<Card>
<template #title>审批队列</template>
<template #extra>
<Space>
<Button :loading="loading" @click="refresh">刷新</Button>
<Button
type="primary"
:disabled="selectedRowKeys.length === 0"
@click="handleBulkApprove"
>
批量通过
</Button>
</Space>
</template>
<Table
:columns="columns"
:data-source="reimbursements"
:loading="loading"
:row-key="(record) => record.id"
:row-selection="rowSelection"
bordered
:scroll="{ x: 820 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'amount'">
<span class="font-medium">
{{ record.amount.toFixed(2) }} {{ record.currency }}
</span>
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="formatStatus(record.status).color">
{{ formatStatus(record.status).label }}
</Tag>
</template>
<template v-else-if="column.key === 'actions'">
<Space>
<Button size="small" type="primary" @click="handleApprove(record)">
通过
</Button>
<Button size="small" danger @click="handleReject(record)">
驳回
</Button>
<Button size="small" type="link" @click="handleView(record)">
查看详情
</Button>
</Space>
</template>
</template>
<template #emptyText>
<span>当前没有待审批的报销申请</span>
</template>
</Table>
</Card>
</div>
</template>

View File

@@ -0,0 +1,316 @@
<script setup lang="ts">
import dayjs, { type Dayjs } from 'dayjs';
import type { FormInstance } from 'ant-design-vue';
import {
Button,
Card,
Col,
DatePicker,
Form,
Input,
InputNumber,
Row,
Select,
Space,
message,
} from 'ant-design-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { FinanceApi } from '#/api/core/finance';
import { useFinanceStore } from '#/store/finance';
import { STATUS_OPTIONS } from './status';
defineOptions({ name: 'FinanceReimbursementCreate' });
const financeStore = useFinanceStore();
const router = useRouter();
const formRef = ref<FormInstance>();
const submitting = ref(false);
interface FormState {
transactionDate: Dayjs | null;
description: string;
project?: string;
categoryId?: number;
accountId?: number;
amount: number | null;
currency: string;
memo?: string;
submittedBy?: string;
reimbursementBatch?: string;
status: FinanceApi.TransactionStatus;
}
const formState = reactive<FormState>({
transactionDate: dayjs(),
description: '',
project: '',
categoryId: undefined,
accountId: undefined,
amount: null,
currency: 'CNY',
memo: '',
submittedBy: '',
reimbursementBatch: '',
status: 'pending',
});
const rules = {
transactionDate: [{ required: true, message: '请选择报销日期' }],
description: [{ required: true, message: '请填写报销内容' }],
amount: [
{ required: true, message: '请输入报销金额' },
{
validator(_: unknown, value: number) {
if (value && value > 0) return Promise.resolve();
return Promise.reject(new Error('金额必须大于 0'));
},
},
],
currency: [{ required: true, message: '请选择币种' }],
};
const currencyOptions = computed(() =>
financeStore.currencies.map((item) => ({
label: `${item.name} (${item.code})`,
value: item.code,
})),
);
const accountOptions = computed(() =>
financeStore.accounts.map((item) => ({
label: `${item.name} · ${item.currency}`,
value: item.id,
})),
);
const categoryOptions = computed(() =>
financeStore.expenseCategories.map((item) => ({
label: item.name,
value: item.id,
})),
);
async function ensureBaseData() {
await Promise.all([
financeStore.currencies.length === 0
? financeStore.fetchCurrencies()
: Promise.resolve(),
financeStore.accounts.length === 0
? financeStore.fetchAccounts()
: Promise.resolve(),
financeStore.expenseCategories.length === 0
? financeStore.fetchCategories()
: Promise.resolve(),
financeStore.reimbursements.length === 0
? financeStore.fetchReimbursements()
: Promise.resolve(),
]);
}
function buildPayload(): FinanceApi.CreateReimbursementParams {
return {
amount: Number(formState.amount),
transactionDate: formState.transactionDate
? formState.transactionDate.format('YYYY-MM-DD')
: dayjs().format('YYYY-MM-DD'),
description: formState.description,
project: formState.project || undefined,
categoryId: formState.categoryId,
accountId: formState.accountId,
currency: formState.currency,
memo: formState.memo || undefined,
submittedBy: formState.submittedBy || undefined,
reimbursementBatch: formState.reimbursementBatch || undefined,
status: formState.status,
};
}
async function handleSubmit() {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
submitting.value = true;
try {
const payload = buildPayload();
const reimbursement = await financeStore.createReimbursement(payload);
message.success('报销申请创建成功');
router.push(`/reimbursement/detail/${reimbursement.id}`);
} catch (error) {
console.error(error);
message.error('创建报销申请失败,请稍后重试');
} finally {
submitting.value = false;
}
}
function handleCancel() {
router.back();
}
function resetForm() {
formRef.value?.resetFields();
formState.transactionDate = dayjs();
formState.description = '';
formState.project = '';
formState.categoryId = undefined;
formState.accountId = undefined;
formState.amount = null;
formState.currency = 'CNY';
formState.memo = '';
formState.submittedBy = '';
formState.reimbursementBatch = '';
formState.status = 'pending';
}
onMounted(async () => {
await ensureBaseData();
});
</script>
<template>
<div class="p-6">
<Card class="max-w-4xl mx-auto">
<template #title>创建报销申请</template>
<Form
ref="formRef"
:model="formState"
:rules="rules"
layout="vertical"
autocomplete="off"
class="space-y-4"
>
<Row :gutter="16">
<Col :xs="24" :md="12">
<Form.Item label="报销日期" name="transactionDate">
<DatePicker
v-model:value="formState.transactionDate"
class="w-full"
format="YYYY-MM-DD"
/>
</Form.Item>
</Col>
<Col :xs="24" :md="12">
<Form.Item label="报销状态" name="status">
<Select
v-model:value="formState.status"
:options="STATUS_OPTIONS"
placeholder="选择状态"
/>
</Form.Item>
</Col>
</Row>
<Form.Item label="报销内容" name="description">
<Input
v-model:value="formState.description"
placeholder="例如:办公设备采购、客户招待等"
/>
</Form.Item>
<Row :gutter="16">
<Col :xs="24" :md="12">
<Form.Item label="所属项目" name="project">
<Input
v-model:value="formState.project"
placeholder="可填写项目名称或成本中心"
allow-clear
/>
</Form.Item>
</Col>
<Col :xs="24" :md="12">
<Form.Item label="费用分类" name="categoryId">
<Select
v-model:value="formState.categoryId"
:options="categoryOptions"
placeholder="请选择费用分类"
allow-clear
/>
</Form.Item>
</Col>
</Row>
<Row :gutter="16">
<Col :xs="24" :md="12">
<Form.Item label="支付账号" name="accountId">
<Select
v-model:value="formState.accountId"
:options="accountOptions"
placeholder="请选择支出账号"
allow-clear
show-search
/>
</Form.Item>
</Col>
<Col :xs="24" :md="12">
<Form.Item label="提交人" name="submittedBy">
<Input
v-model:value="formState.submittedBy"
placeholder="填写报销提交人或责任人"
allow-clear
/>
</Form.Item>
</Col>
</Row>
<Row :gutter="16">
<Col :xs="24" :md="12">
<Form.Item label="报销金额" name="amount">
<InputNumber
v-model:value="formState.amount"
:precision="2"
:min="0"
class="w-full"
placeholder="请输入金额"
/>
</Form.Item>
</Col>
<Col :xs="24" :md="12">
<Form.Item label="币种" name="currency">
<Select
v-model:value="formState.currency"
:options="currencyOptions"
placeholder="请选择币种"
show-search
/>
</Form.Item>
</Col>
</Row>
<Form.Item label="报销批次" name="reimbursementBatch">
<Input
v-model:value="formState.reimbursementBatch"
placeholder="可填写批次号或审批编号"
allow-clear
/>
</Form.Item>
<Form.Item label="备注" name="memo">
<Input.TextArea
v-model:value="formState.memo"
:rows="4"
placeholder="补充说明、附件信息等"
allow-clear
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" :loading="submitting" @click="handleSubmit">
提交
</Button>
<Button :disabled="submitting" @click="resetForm">重置</Button>
<Button :disabled="submitting" @click="handleCancel">取消</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
</template>

View File

@@ -0,0 +1,383 @@
<script setup lang="ts">
import dayjs from 'dayjs';
import {
Button,
Card,
Descriptions,
Form,
Input,
InputNumber,
Popconfirm,
Select,
Space,
Tag,
message,
} from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { FinanceApi } from '#/api/core/finance';
import { useFinanceStore } from '#/store/finance';
import { STATUS_OPTIONS, formatStatus } from './status';
defineOptions({ name: 'FinanceReimbursementDetail' });
const route = useRoute();
const router = useRouter();
const financeStore = useFinanceStore();
const reimbursementId = Number(route.params.id);
const loading = ref(false);
const saving = ref(false);
const formRef = ref<FormInstance>();
const reimbursement = computed(() =>
financeStore.reimbursements.find((item) => item.id === reimbursementId),
);
const formState = reactive({
description: '',
amount: 0,
currency: 'CNY',
project: '',
memo: '',
submittedBy: '',
approvedBy: '',
reimbursementBatch: '',
reviewNotes: '',
status: 'pending' as FinanceApi.TransactionStatus,
});
const rules = {
description: [{ required: true, message: '请填写报销内容' }],
amount: [
{ required: true, message: '请输入金额' },
{
validator(_: unknown, value: number) {
if (value && value > 0) return Promise.resolve();
return Promise.reject(new Error('金额需大于 0'));
},
},
],
};
const canEdit = computed(() => !reimbursement.value?.isDeleted);
watch(
reimbursement,
(value) => {
if (!value) return;
formState.description = value.description;
formState.amount = value.amount;
formState.currency = value.currency;
formState.project = value.project ?? '';
formState.memo = value.memo ?? '';
formState.submittedBy = value.submittedBy ?? '';
formState.approvedBy = value.approvedBy ?? '';
formState.reimbursementBatch = value.reimbursementBatch ?? '';
formState.reviewNotes = value.reviewNotes ?? '';
formState.status = value.status;
},
{ immediate: true },
);
async function ensureData() {
if (
financeStore.reimbursements.length === 0 ||
!reimbursement.value
) {
loading.value = true;
try {
await financeStore.fetchReimbursements({ includeDeleted: true });
} finally {
loading.value = false;
}
}
}
async function handleSave() {
if (!reimbursement.value) return;
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
saving.value = true;
try {
await financeStore.updateReimbursement(reimbursement.value.id, {
description: formState.description,
amount: formState.amount,
currency: formState.currency,
project: formState.project || null,
memo: formState.memo || null,
submittedBy: formState.submittedBy || null,
approvedBy: formState.approvedBy || null,
reimbursementBatch: formState.reimbursementBatch || null,
reviewNotes: formState.reviewNotes || null,
status: formState.status,
});
message.success('信息已更新');
} catch (error) {
console.error(error);
message.error('更新失败,请稍后重试');
} finally {
saving.value = false;
}
}
async function handleApprove() {
if (!reimbursement.value) return;
try {
await financeStore.updateReimbursement(reimbursement.value.id, {
status: 'approved',
});
message.success('已审批通过');
} catch (error) {
console.error(error);
message.error('审批失败,请稍后重试');
}
}
async function handleReject() {
if (!reimbursement.value) return;
try {
await financeStore.updateReimbursement(reimbursement.value.id, {
status: 'rejected',
reviewNotes: formState.reviewNotes || reimbursement.value.reviewNotes,
});
message.success('已驳回');
} catch (error) {
console.error(error);
message.error('驳回失败,请稍后重试');
}
}
async function handleMarkPaid() {
if (!reimbursement.value) return;
try {
await financeStore.updateReimbursement(reimbursement.value.id, {
status: 'paid',
});
message.success('已标记为报销完成');
} catch (error) {
console.error(error);
message.error('操作失败,请稍后重试');
}
}
async function handleRestore() {
if (!reimbursement.value) return;
try {
await financeStore.restoreReimbursement(reimbursement.value.id);
message.success('已恢复报销单');
} catch (error) {
console.error(error);
message.error('恢复失败,请稍后重试');
}
}
async function handleDelete() {
if (!reimbursement.value) return;
try {
await financeStore.deleteReimbursement(reimbursement.value.id);
message.success('已移入回收站');
} catch (error) {
console.error(error);
message.error('操作失败,请稍后重试');
}
}
function goBack() {
router.back();
}
onMounted(() => {
ensureData();
});
</script>
<template>
<div class="p-6 space-y-4">
<Button @click="goBack">返回</Button>
<Card v-if="loading">
<span>加载中...</span>
</Card>
<Card v-else-if="!reimbursement">
<Space direction="vertical">
<span>未找到报销单记录可能已被删除或编号错误</span>
<Button type="primary" @click="goBack">返回列表</Button>
</Space>
</Card>
<template v-else>
<Card>
<template #title>报销概览</template>
<Space direction="vertical" class="w-full">
<Space align="center">
<Tag :color="formatStatus(reimbursement.status).color">
{{ formatStatus(reimbursement.status).label }}
</Tag>
<span class="text-gray-500 text-sm">
创建于 {{ dayjs(reimbursement.createdAt).format('YYYY-MM-DD HH:mm') }}
</span>
</Space>
<Descriptions :column="1" size="small" bordered>
<Descriptions.Item label="报销内容">
{{ reimbursement.description || '未填写' }}
</Descriptions.Item>
<Descriptions.Item label="报销日期">
{{ dayjs(reimbursement.transactionDate).format('YYYY-MM-DD') }}
</Descriptions.Item>
<Descriptions.Item label="金额">
{{ reimbursement.amount.toFixed(2) }} {{ reimbursement.currency }}
折合 {{ reimbursement.amountInBase.toFixed(2) }} CNY
</Descriptions.Item>
<Descriptions.Item label="所属项目">
{{ reimbursement.project || '未指定' }}
</Descriptions.Item>
<Descriptions.Item label="提交人">
{{ reimbursement.submittedBy || '未填写' }}
</Descriptions.Item>
<Descriptions.Item label="审批人">
{{ reimbursement.approvedBy || '未指定' }}
</Descriptions.Item>
<Descriptions.Item label="审批备注">
{{ reimbursement.reviewNotes || '暂无' }}
</Descriptions.Item>
<Descriptions.Item label="批次号">
{{ reimbursement.reimbursementBatch || '未设置' }}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ dayjs(reimbursement.createdAt).format('YYYY-MM-DD HH:mm') }}
</Descriptions.Item>
</Descriptions>
</Space>
</Card>
<Card>
<template #title>更新报销信息</template>
<template #extra>
<Space>
<Button type="primary" :loading="saving" :disabled="!canEdit" @click="handleSave">
保存变更
</Button>
<Button :disabled="!canEdit" @click="handleApprove">
审批通过
</Button>
<Button danger :disabled="!canEdit" @click="handleReject">
驳回
</Button>
<Button :disabled="!canEdit" @click="handleMarkPaid">
标记已报销
</Button>
<Popconfirm
v-if="!reimbursement.isDeleted"
title="确定要删除该报销单?"
ok-text="删除"
cancel-text="取消"
@confirm="handleDelete"
>
<Button danger type="link">移入回收站</Button>
</Popconfirm>
<Button
v-else
type="link"
@click="handleRestore"
>
恢复
</Button>
</Space>
</template>
<Form
ref="formRef"
:model="formState"
:rules="rules"
layout="vertical"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<Form.Item label="报销内容" name="description">
<Input
v-model:value="formState.description"
:disabled="!canEdit"
placeholder="描述报销事项"
/>
</Form.Item>
<Form.Item label="金额" name="amount">
<InputNumber
v-model:value="formState.amount"
:disabled="!canEdit"
:precision="2"
:min="0"
class="w-full"
/>
</Form.Item>
<Form.Item label="币种" name="currency">
<Input
v-model:value="formState.currency"
:disabled="!canEdit"
placeholder="例如 CNY"
/>
</Form.Item>
<Form.Item label="所属项目" name="project">
<Input
v-model:value="formState.project"
:disabled="!canEdit"
placeholder="可填写项目或成本中心"
/>
</Form.Item>
<Form.Item label="备注" name="memo">
<Input.TextArea
v-model:value="formState.memo"
:disabled="!canEdit"
:rows="3"
placeholder="补充说明"
/>
</Form.Item>
<Form.Item label="提交人" name="submittedBy">
<Input
v-model:value="formState.submittedBy"
:disabled="!canEdit"
placeholder="报销申请人"
/>
</Form.Item>
<Form.Item label="审批人" name="approvedBy">
<Input
v-model:value="formState.approvedBy"
:disabled="!canEdit"
placeholder="审批负责人"
/>
</Form.Item>
<Form.Item label="审批备注" name="reviewNotes">
<Input.TextArea
v-model:value="formState.reviewNotes"
:rows="3"
:disabled="!canEdit"
placeholder="审批意见、驳回原因等"
/>
</Form.Item>
<Form.Item label="批次号" name="reimbursementBatch">
<Input
v-model:value="formState.reimbursementBatch"
:disabled="!canEdit"
placeholder="报销批次或审批编号"
/>
</Form.Item>
<Form.Item label="状态" name="status">
<Select
v-model:value="formState.status"
:options="STATUS_OPTIONS"
:disabled="!canEdit"
/>
</Form.Item>
</Form>
</Card>
</template>
</div>
</template>

View File

@@ -0,0 +1,429 @@
<script setup lang="ts">
import type { TableColumnsType, TableRowSelection } from 'ant-design-vue';
import dayjs, { type Dayjs } from 'dayjs';
import {
Button,
Card,
Col,
DatePicker,
Dropdown,
Input,
Menu,
Modal,
Row,
Select,
Space,
Statistic,
Switch,
Table,
Tag,
message,
} from 'ant-design-vue';
import { computed, h, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { FinanceApi } from '#/api/core/finance';
import { useFinanceStore } from '#/store/finance';
import { STATUS_CONFIG, STATUS_OPTIONS, formatStatus } from './status';
defineOptions({ name: 'FinanceReimbursementList' });
const financeStore = useFinanceStore();
const router = useRouter();
const keyword = ref('');
const statusFilter = ref<FinanceApi.TransactionStatus[]>(
STATUS_OPTIONS.map((item) => item.value),
);
const dateRange = ref<[Dayjs, Dayjs] | null>(null);
const includeDeleted = ref(false);
const selectedRowKeys = ref<number[]>([]);
const batchStatus = ref<FinanceApi.TransactionStatus>('approved');
const RangePicker = DatePicker.RangePicker;
const loading = computed(() => financeStore.loading.reimbursements);
const reimbursements = computed(() =>
financeStore.reimbursements.slice().sort((a, b) => {
const statusOrder =
STATUS_CONFIG[a.status].order - STATUS_CONFIG[b.status].order;
if (statusOrder !== 0) {
return statusOrder;
}
return dayjs(b.transactionDate).valueOf() -
dayjs(a.transactionDate).valueOf();
}),
);
const filteredReimbursements = computed(() => {
return reimbursements.value.filter((item) => {
if (!includeDeleted.value && item.isDeleted) {
return false;
}
if (
statusFilter.value.length > 0 &&
!statusFilter.value.includes(item.status)
) {
return false;
}
if (dateRange.value) {
const [start, end] = dateRange.value;
const date = dayjs(item.transactionDate);
if (
date.isBefore(start.startOf('day')) ||
date.isAfter(end.endOf('day'))
) {
return false;
}
}
if (keyword.value.trim().length > 0) {
const text = keyword.value.trim().toLowerCase();
const matcher = [
item.description,
item.project,
item.memo,
item.submittedBy,
item.reimbursementBatch,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
if (!matcher.includes(text)) {
return false;
}
}
return true;
});
});
const statusSummary = computed(() => {
const summary = new Map<
FinanceApi.TransactionStatus,
{ count: number; amount: number; baseAmount: number }
>();
for (const status of Object.keys(STATUS_CONFIG) as FinanceApi.TransactionStatus[]) {
summary.set(status, { count: 0, amount: 0, baseAmount: 0 });
}
reimbursements.value.forEach((item) => {
const target = summary.get(item.status);
if (!target) return;
target.count += 1;
target.amount += item.amount;
target.baseAmount += item.amountInBase ?? 0;
});
return Array.from(summary.entries()).sort(
(a, b) => STATUS_CONFIG[a[0]].order - STATUS_CONFIG[b[0]].order,
);
});
const columns: TableColumnsType<FinanceApi.Transaction> = [
{
title: '报销内容',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '状态',
key: 'status',
width: 120,
},
{
title: '日期',
dataIndex: 'transactionDate',
key: 'transactionDate',
width: 140,
},
{
title: '金额',
key: 'amount',
width: 160,
},
{
title: '项目/分类',
key: 'project',
ellipsis: true,
},
{
title: '提交人',
dataIndex: 'submittedBy',
key: 'submittedBy',
width: 140,
},
{
title: '批次',
dataIndex: 'reimbursementBatch',
key: 'reimbursementBatch',
width: 140,
},
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 220,
},
];
const rowSelection = computed<TableRowSelection<FinanceApi.Transaction>>(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys as number[];
},
}));
function resetFilters() {
keyword.value = '';
statusFilter.value = STATUS_OPTIONS.map((item) => item.value);
dateRange.value = null;
includeDeleted.value = false;
}
async function updateStatus(
record: FinanceApi.Transaction,
status: FinanceApi.TransactionStatus,
extra: Partial<FinanceApi.CreateReimbursementParams> = {},
) {
await financeStore.updateReimbursement(record.id, {
status,
...extra,
});
message.success('状态已更新');
}
function handleApprove(record: FinanceApi.Transaction) {
updateStatus(record, 'approved');
}
function handleReject(record: FinanceApi.Transaction) {
let notes = record.reviewNotes ?? '';
Modal.confirm({
title: '确认驳回报销申请?',
content: () =>
h(Input.TextArea, {
value: notes,
placeholder: '请输入驳回原因',
rows: 3,
onChange: (event: any) => {
notes = event.target.value;
},
}),
okText: '驳回',
cancelText: '取消',
okButtonProps: { danger: true },
async onOk() {
await updateStatus(record, 'rejected', { reviewNotes: notes });
},
});
}
function handleMarkPaid(record: FinanceApi.Transaction) {
updateStatus(record, 'paid', {
approvedBy: record.approvedBy ?? 'system',
});
}
function handleMoveToPending(record: FinanceApi.Transaction) {
updateStatus(record, 'pending');
}
async function handleBulkUpdate(status: FinanceApi.TransactionStatus) {
if (selectedRowKeys.value.length === 0) return;
const targets = financeStore.reimbursements.filter((item) =>
selectedRowKeys.value.includes(item.id),
);
await Promise.all(
targets.map((item) =>
financeStore.updateReimbursement(item.id, { status }),
),
);
message.success('批量操作完成');
selectedRowKeys.value = [];
}
function handleView(record: FinanceApi.Transaction) {
router.push(`/reimbursement/detail/${record.id}`);
}
function handleCreate() {
router.push('/reimbursement/create');
}
onMounted(async () => {
if (financeStore.reimbursements.length === 0) {
await financeStore.fetchReimbursements();
}
});
</script>
<template>
<div class="p-6 space-y-4">
<Card>
<template #title>报销概览</template>
<Row :gutter="16">
<Col
v-for="[status, item] in statusSummary"
:key="status"
:xs="24"
:sm="12"
:md="8"
:lg="6"
>
<Card size="small" :bordered="false">
<Space direction="vertical" class="w-full">
<Space align="center">
<Tag :color="formatStatus(status).color">
{{ formatStatus(status).label }}
</Tag>
<span class="text-xs text-gray-500">{{
formatStatus(status).description
}}</span>
</Space>
<Statistic title="单据数量" :value="item.count" />
<Statistic
title="金额(原币)"
:precision="2"
:value="item.amount"
/>
<Statistic
title="金额折合CNY"
:precision="2"
:value="item.baseAmount"
/>
</Space>
</Card>
</Col>
</Row>
</Card>
<Card>
<template #title>筛选条件</template>
<template #extra>
<Space>
<Button type="link" @click="resetFilters">重置</Button>
<Button type="primary" @click="handleCreate">新增报销</Button>
</Space>
</template>
<Space :size="12" wrap>
<Input
v-model:value="keyword"
allow-clear
style="width: 240px"
placeholder="搜索描述 / 项目 / 提交人"
/>
<Select
v-model:value="statusFilter"
:options="STATUS_OPTIONS"
mode="multiple"
allow-clear
placeholder="选择状态"
style="min-width: 220px"
/>
<RangePicker
v-model:value="dateRange"
allow-clear
placeholder="请选择日期范围"
/>
<Space align="center">
<Switch v-model:checked="includeDeleted" size="small" />
<span class="text-xs text-gray-500">显示已删除</span>
</Space>
<Select
v-model:value="batchStatus"
:options="STATUS_OPTIONS"
style="min-width: 160px"
/>
<Button
:disabled="selectedRowKeys.length === 0"
@click="handleBulkUpdate(batchStatus)"
>
批量更新状态
</Button>
</Space>
</Card>
<Card>
<template #title>报销列表</template>
<template #extra>
<span class="text-sm text-gray-500"
> {{ filteredReimbursements.length }} 条记录</span
>
</template>
<Table
:columns="columns"
:data-source="filteredReimbursements"
:loading="loading"
:row-key="(record) => record.id"
:row-selection="rowSelection"
:scroll="{ x: 960 }"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<Tag :color="formatStatus(record.status).color">
{{ formatStatus(record.status).label }}
</Tag>
</template>
<template v-else-if="column.key === 'amount'">
<Space direction="vertical" size="small">
<span class="font-medium">
{{ record.amount.toFixed(2) }} {{ record.currency }}
</span>
<span class="text-xs text-gray-500">
折合 {{ record.amountInBase.toFixed(2) }} CNY
</span>
</Space>
</template>
<template v-else-if="column.key === 'project'">
<Space direction="vertical" size="small">
<span class="font-medium">{{ record.project || '未指定' }}</span>
<span class="text-xs text-gray-500">
{{ record.memo || '无备注' }}
</span>
</Space>
</template>
<template v-else-if="column.key === 'actions'">
<Space>
<Button size="small" type="link" @click="handleView(record)">
查看
</Button>
<Dropdown>
<template #overlay>
<Menu>
<Menu.Item
key="approve"
@click="handleApprove(record)"
>
审批通过
</Menu.Item>
<Menu.Item
key="reject"
danger
@click="handleReject(record)"
>
驳回
</Menu.Item>
<Menu.Item key="paid" @click="handleMarkPaid(record)">
标记报销
</Menu.Item>
<Menu.Item
key="pending"
@click="handleMoveToPending(record)"
>
重新提交
</Menu.Item>
</Menu>
</template>
<Button size="small">更多</Button>
</Dropdown>
</Space>
</template>
</template>
</Table>
</Card>
</div>
</template>

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
import { Card, Progress, Skeleton, Statistic, Table, Tag } from 'ant-design-vue';
import { computed, onMounted } from 'vue';
import { useFinanceStore } from '#/store/finance';
import { STATUS_CONFIG, formatStatus } from './status';
defineOptions({ name: 'FinanceReimbursementStatistics' });
const financeStore = useFinanceStore();
const loading = computed(() => financeStore.loading.reimbursements);
const statusSummary = computed(() => {
const summary = new Map<
string,
{ count: number; baseAmount: number }
>();
(Object.keys(STATUS_CONFIG) as Array<keyof typeof STATUS_CONFIG>).forEach(
(status) => {
summary.set(status, { count: 0, baseAmount: 0 });
},
);
financeStore.reimbursements.forEach((item) => {
const key = summary.get(item.status);
if (!key) return;
key.count += 1;
key.baseAmount += item.amountInBase ?? 0;
});
const totalCount = Array.from(summary.values()).reduce(
(acc, cur) => acc + cur.count,
0,
);
return Array.from(summary.entries()).map(([status, value]) => ({
status,
count: value.count,
baseAmount: value.baseAmount,
percentage: totalCount === 0 ? 0 : Math.round((value.count / totalCount) * 100),
}));
});
const monthlySummary = computed(() => {
const map = new Map<
string,
{ total: number; baseAmount: number; approved: number; pending: number }
>();
financeStore.reimbursements.forEach((item) => {
const month = item.transactionDate.slice(0, 7);
if (!map.has(month)) {
map.set(month, { total: 0, baseAmount: 0, approved: 0, pending: 0 });
}
const target = map.get(month)!;
target.total += item.amount;
target.baseAmount += item.amountInBase ?? 0;
if (item.status === 'approved' || item.status === 'paid') {
target.approved += item.amountInBase ?? 0;
}
if (item.status === 'draft' || item.status === 'pending') {
target.pending += item.amountInBase ?? 0;
}
});
return Array.from(map.entries())
.map(([month, value]) => ({
month,
...value,
}))
.sort((a, b) => (a.month < b.month ? 1 : -1))
.slice(0, 12);
});
const projectSummary = computed(() => {
const map = new Map<
string,
{ count: number; baseAmount: number }
>();
financeStore.reimbursements.forEach((item) => {
const key = item.project || '未指定项目';
if (!map.has(key)) {
map.set(key, { count: 0, baseAmount: 0 });
}
const target = map.get(key)!;
target.count += 1;
target.baseAmount += item.amountInBase ?? 0;
});
return Array.from(map.entries())
.map(([project, value]) => ({
project,
...value,
}))
.sort((a, b) => b.baseAmount - a.baseAmount)
.slice(0, 10);
});
onMounted(() => {
if (financeStore.reimbursements.length === 0) {
financeStore.fetchReimbursements();
}
});
</script>
<template>
<div class="p-6 space-y-4">
<Card title="状态分布">
<Skeleton :loading="loading" active>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div
v-for="item in statusSummary"
:key="item.status"
class="border rounded-md p-4 space-y-3"
>
<div class="flex items-center justify-between">
<Tag :color="formatStatus(item.status).color">
{{ formatStatus(item.status).label }}
</Tag>
<span class="text-sm text-gray-500">
{{ formatStatus(item.status).description }}
</span>
</div>
<Statistic title="单据数量" :value="item.count" />
<Statistic
title="折合金额 (CNY)"
:precision="2"
:value="item.baseAmount"
/>
<Progress :percent="item.percentage" />
</div>
</div>
</Skeleton>
</Card>
<Card title="按月统计最近12个月">
<Skeleton :loading="loading" active>
<Table
:data-source="monthlySummary"
:pagination="false"
row-key="month"
size="small"
bordered
>
<Table.Column title="月份" dataIndex="month" key="month" />
<Table.Column
title="原币合计"
key="total"
customRender="total"
>
<template #bodyCell="{ record }">
{{ record.total.toFixed(2) }}
</template>
</Table.Column>
<Table.Column
title="折合CNY"
key="baseAmount"
customRender="baseAmount"
>
<template #bodyCell="{ record }">
{{ record.baseAmount.toFixed(2) }}
</template>
</Table.Column>
<Table.Column
title="已批准金额 (CNY)"
key="approved"
customRender="approved"
>
<template #bodyCell="{ record }">
{{ record.approved.toFixed(2) }}
</template>
</Table.Column>
<Table.Column
title="待审批金额 (CNY)"
key="pending"
customRender="pending"
>
<template #bodyCell="{ record }">
{{ record.pending.toFixed(2) }}
</template>
</Table.Column>
</Table>
</Skeleton>
</Card>
<Card title="项目费用 Top 10">
<Skeleton :loading="loading" active>
<Table
:data-source="projectSummary"
:pagination="false"
row-key="project"
size="small"
bordered
>
<Table.Column title="项目" dataIndex="project" key="project" />
<Table.Column title="单据数量" dataIndex="count" key="count" />
<Table.Column
title="折合金额 (CNY)"
key="baseAmount"
customRender="baseAmount"
>
<template #bodyCell="{ record }">
{{ record.baseAmount.toFixed(2) }}
</template>
</Table.Column>
</Table>
</Skeleton>
</Card>
</div>
</template>

View File

@@ -0,0 +1,48 @@
import type { FinanceApi } from '#/api/core/finance';
export const STATUS_CONFIG: Record<
FinanceApi.TransactionStatus,
{ label: string; color: string; description: string; order: number }
> = {
draft: {
label: '草稿',
color: 'default',
description: '尚未提交或待完善的报销信息',
order: 0,
},
pending: {
label: '待审批',
color: 'processing',
description: '等待审批人审核的报销申请',
order: 1,
},
approved: {
label: '已通过',
color: 'success',
description: '审批通过,待支付或报销完成',
order: 2,
},
rejected: {
label: '已驳回',
color: 'error',
description: '审批被驳回,需要发起人处理',
order: 3,
},
paid: {
label: '已报销',
color: 'purple',
description: '已完成报销或费用报销入账',
order: 4,
},
};
export const STATUS_OPTIONS = Object.entries(STATUS_CONFIG)
.sort((a, b) => a[1].order - b[1].order)
.map(([value, config]) => ({
label: `${config.label}`,
value: value as FinanceApi.TransactionStatus,
}));
export function formatStatus(status: FinanceApi.TransactionStatus) {
return STATUS_CONFIG[status] ?? STATUS_CONFIG.pending;
}

View File

@@ -233,19 +233,19 @@ const exportToExcel = (title: string, timestamp: string) => {
['指标', '金额', '', ''], ['指标', '金额', '', ''],
[ [
'总收入', '总收入',
`¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`, `$${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
'', '',
'', '',
], ],
[ [
'总支出', '总支出',
`¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`, `$${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
'', '',
'', '',
], ],
[ [
'净收入', '净收入',
`¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`, `$${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
'', '',
'', '',
], ],
@@ -314,9 +314,9 @@ const exportToCSV = (title: string, timestamp: string) => {
if (exportOptions.value.includeSummary) { if (exportOptions.value.includeSummary) {
csvContent += '核心指标汇总\n'; csvContent += '核心指标汇总\n';
csvContent += '指标,金额\n'; csvContent += '指标,金额\n';
csvContent += `总收入,¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`; csvContent += `总收入,$${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
csvContent += `总支出,¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`; csvContent += `总支出,$${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
csvContent += `净收入,¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`; csvContent += `净收入,$${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
csvContent += `交易笔数,${periodTransactions.value.length}\n\n`; csvContent += `交易笔数,${periodTransactions.value.length}\n\n`;
} }
@@ -401,15 +401,15 @@ const printReport = () => {
<div class="summary"> <div class="summary">
<div class="summary-card"> <div class="summary-card">
<div class="label">总收入</div> <div class="label">总收入</div>
<div class="value income">¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div> <div class="value income">$${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
</div> </div>
<div class="summary-card"> <div class="summary-card">
<div class="label">总支出</div> <div class="label">总支出</div>
<div class="value expense">¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div> <div class="value expense">$${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
</div> </div>
<div class="summary-card"> <div class="summary-card">
<div class="label">净收入</div> <div class="label">净收入</div>
<div class="value net">${periodNet.value >= 0 ? '+' : ''}¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div> <div class="value net">${periodNet.value >= 0 ? '+' : ''}$${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
</div> </div>
<div class="summary-card"> <div class="summary-card">
<div class="label">交易笔数</div> <div class="label">交易笔数</div>
@@ -427,9 +427,9 @@ const printReport = () => {
(item) => ` (item) => `
<div class="category-item"> <div class="category-item">
<span class="category-name">${item.categoryName}</span> <span class="category-name">${item.categoryName}</span>
<span class="category-amount income">¥${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> <span class="category-amount income">$${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
<div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;"> <div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;">
${item.count} 笔 · 平均 ¥${(item.amount / item.count).toFixed(2)} · ${item.percentage}% ${item.count} 笔 · 平均 $${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
</div> </div>
</div> </div>
`, `,
@@ -450,9 +450,9 @@ const printReport = () => {
(item) => ` (item) => `
<div class="category-item"> <div class="category-item">
<span class="category-name">${item.categoryName}</span> <span class="category-name">${item.categoryName}</span>
<span class="category-amount expense">¥${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> <span class="category-amount expense">$${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
<div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;"> <div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;">
${item.count} 笔 · 平均 ¥${(item.amount / item.count).toFixed(2)} · ${item.percentage}% ${item.count} 笔 · 平均 $${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
</div> </div>
</div> </div>
`, `,
@@ -488,7 +488,7 @@ const printReport = () => {
<td>${t.description || ''}</td> <td>${t.description || ''}</td>
<td>${getCategoryName(t.categoryId)}</td> <td>${getCategoryName(t.categoryId)}</td>
<td class="${t.type === 'income' ? 'income' : 'expense'}"> <td class="${t.type === 'income' ? 'income' : 'expense'}">
${t.type === 'income' ? '+' : '-'}¥${Math.abs(t.amount).toLocaleString()} ${t.type === 'income' ? '+' : '-'}$${Math.abs(t.amount).toLocaleString()}
</td> </td>
<td>${getAccountName(t.accountId)}</td> <td>${getAccountName(t.accountId)}</td>
</tr> </tr>
@@ -696,7 +696,7 @@ onMounted(async () => {
<div class="mb-2 text-3xl">💰</div> <div class="mb-2 text-3xl">💰</div>
<p class="text-sm text-gray-500">总收入</p> <p class="text-sm text-gray-500">总收入</p>
<p class="text-2xl font-bold text-green-600"> <p class="text-2xl font-bold text-green-600">
¥{{ ${{
periodIncome.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) periodIncome.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
}} }}
</p> </p>
@@ -705,7 +705,7 @@ onMounted(async () => {
<div class="mb-2 text-3xl">💸</div> <div class="mb-2 text-3xl">💸</div>
<p class="text-sm text-gray-500">总支出</p> <p class="text-sm text-gray-500">总支出</p>
<p class="text-2xl font-bold text-red-600"> <p class="text-2xl font-bold text-red-600">
¥{{ ${{
periodExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) periodExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
}} }}
</p> </p>
@@ -717,7 +717,7 @@ onMounted(async () => {
class="text-2xl font-bold" class="text-2xl font-bold"
:class="periodNet >= 0 ? 'text-purple-600' : 'text-red-600'" :class="periodNet >= 0 ? 'text-purple-600' : 'text-red-600'"
> >
{{ periodNet >= 0 ? '+' : '' }}¥{{ {{ periodNet >= 0 ? '+' : '' }}${{
periodNet.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) periodNet.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
}} }}
</p> </p>
@@ -748,7 +748,7 @@ onMounted(async () => {
<div class="mb-2 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
<span class="font-medium">{{ item.categoryName }}</span> <span class="font-medium">{{ item.categoryName }}</span>
<span class="text-sm font-bold text-green-600"> <span class="text-sm font-bold text-green-600">
¥{{ ${{
item.amount.toLocaleString('zh-CN', { item.amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
@@ -767,7 +767,7 @@ onMounted(async () => {
> >
</div> </div>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500">
{{ item.count }} · 平均 ¥{{ {{ item.count }} · 平均 ${{
(item.amount / item.count).toFixed(2) (item.amount / item.count).toFixed(2)
}} }}
</p> </p>
@@ -790,7 +790,7 @@ onMounted(async () => {
<div class="mb-2 flex items-center justify-between"> <div class="mb-2 flex items-center justify-between">
<span class="font-medium">{{ item.categoryName }}</span> <span class="font-medium">{{ item.categoryName }}</span>
<span class="text-sm font-bold text-red-600"> <span class="text-sm font-bold text-red-600">
¥{{ ${{
item.amount.toLocaleString('zh-CN', { item.amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
@@ -809,7 +809,7 @@ onMounted(async () => {
> >
</div> </div>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500">
{{ item.count }} · 平均 ¥{{ {{ item.count }} · 平均 ${{
(item.amount / item.count).toFixed(2) (item.amount / item.count).toFixed(2)
}} }}
</p> </p>
@@ -840,7 +840,7 @@ onMounted(async () => {
: 'font-bold text-red-600' : 'font-bold text-red-600'
" "
> >
{{ record.type === 'income' ? '+' : '-' }}¥{{ {{ record.type === 'income' ? '+' : '-' }}${{
Math.abs(record.amount).toLocaleString() Math.abs(record.amount).toLocaleString()
}} }}
</span> </span>

View File

@@ -167,7 +167,7 @@ const smartInsights = computed(() => {
type: 'expense_trend', type: 'expense_trend',
icon: '📉', icon: '📉',
title: '支出趋势', title: '支出趋势',
description: `本期总支出 ¥${currentPeriodData.value.totalExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`, description: `本期总支出 $${currentPeriodData.value.totalExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
value: `${currentPeriodData.value.expenseCount}`, value: `${currentPeriodData.value.expenseCount}`,
trend: null, trend: null,
valueClass: 'text-red-600', valueClass: 'text-red-600',
@@ -213,7 +213,7 @@ const smartInsights = computed(() => {
icon: '💎', icon: '💎',
title: '平均单笔', title: '平均单笔',
description: '本期平均每笔支出金额', description: '本期平均每笔支出金额',
value: `¥${currentPeriodData.value.avgAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`, value: `$${currentPeriodData.value.avgAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
trend: null, trend: null,
valueClass: 'text-purple-600', valueClass: 'text-purple-600',
trendClass: '', trendClass: '',
@@ -432,7 +432,7 @@ const initCashFlowChart = () => {
yAxis: { yAxis: {
type: 'value', type: 'value',
axisLabel: { axisLabel: {
formatter: '¥{value}', formatter: '${value}',
}, },
}, },
series: [ series: [
@@ -496,7 +496,7 @@ const initExpenseTreeChart = () => {
const option = { const option = {
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: '{b}: ¥{c} ({d}%)', formatter: '{b}: ${c} ({d}%)',
}, },
series: [ series: [
{ {
@@ -505,7 +505,7 @@ const initExpenseTreeChart = () => {
leafDepth: 1, leafDepth: 1,
label: { label: {
show: true, show: true,
formatter: '{b}\n¥{c}', formatter: '{b}\n${c}',
}, },
upperLabel: { upperLabel: {
show: true, show: true,
@@ -823,7 +823,9 @@ window.addEventListener('resize', () => {
style="width: 200px" style="width: 200px"
/> />
<Button @click="nextPeriod" :disabled="!canGoNext"> </Button> <Button @click="nextPeriod" :disabled="!canGoNext"> </Button>
<span class="text-gray-500"> {{ currentPeriodData.transactionCount }} 笔交易</span> <span class="text-gray-500"
> {{ currentPeriodData.transactionCount }} 笔交易</span
>
</div> </div>
</TabPane> </TabPane>
<TabPane key="quarterly" tab="📊 季度分析"> <TabPane key="quarterly" tab="📊 季度分析">
@@ -845,7 +847,9 @@ window.addEventListener('resize', () => {
</Select.Option> </Select.Option>
</Select> </Select>
<Button @click="nextPeriod" :disabled="!canGoNext"> </Button> <Button @click="nextPeriod" :disabled="!canGoNext"> </Button>
<span class="text-gray-500"> {{ currentPeriodData.transactionCount }} 笔交易</span> <span class="text-gray-500"
> {{ currentPeriodData.transactionCount }} 笔交易</span
>
</div> </div>
</TabPane> </TabPane>
<TabPane key="yearly" tab="📈 年度分析"> <TabPane key="yearly" tab="📈 年度分析">
@@ -867,7 +871,9 @@ window.addEventListener('resize', () => {
</Select.Option> </Select.Option>
</Select> </Select>
<Button @click="nextPeriod" :disabled="!canGoNext"> </Button> <Button @click="nextPeriod" :disabled="!canGoNext"> </Button>
<span class="text-gray-500"> {{ currentPeriodData.transactionCount }} 笔交易</span> <span class="text-gray-500"
> {{ currentPeriodData.transactionCount }} 笔交易</span
>
</div> </div>
</TabPane> </TabPane>
<TabPane key="custom" tab="🎯 自定义"> <TabPane key="custom" tab="🎯 自定义">
@@ -877,7 +883,9 @@ window.addEventListener('resize', () => {
format="YYYY-MM-DD" format="YYYY-MM-DD"
@change="handleCustomRangeChange" @change="handleCustomRangeChange"
/> />
<span class="text-gray-500"> {{ currentPeriodData.transactionCount }} 笔交易</span> <span class="text-gray-500"
> {{ currentPeriodData.transactionCount }} 笔交易</span
>
</div> </div>
</TabPane> </TabPane>
</Tabs> </Tabs>
@@ -907,7 +915,9 @@ window.addEventListener('resize', () => {
class="text-sm" class="text-sm"
:class="insight.trendClass" :class="insight.trendClass"
> >
<template v-if="insight.trend > 0"> +{{ insight.trend }}%</template> <template v-if="insight.trend > 0"
> +{{ insight.trend }}%</template
>
<template v-else> {{ insight.trend }}%</template> <template v-else> {{ insight.trend }}%</template>
</span> </span>
</div> </div>
@@ -922,7 +932,7 @@ window.addEventListener('resize', () => {
<div class="text-3xl">💰</div> <div class="text-3xl">💰</div>
<p class="text-sm text-gray-500">期间总收入</p> <p class="text-sm text-gray-500">期间总收入</p>
<p class="text-2xl font-bold text-green-600"> <p class="text-2xl font-bold text-green-600">
¥{{ ${{
currentPeriodData.totalIncome.toLocaleString('zh-CN', { currentPeriodData.totalIncome.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
@@ -946,7 +956,7 @@ window.addEventListener('resize', () => {
<div class="text-3xl">💸</div> <div class="text-3xl">💸</div>
<p class="text-sm text-gray-500">期间总支出</p> <p class="text-sm text-gray-500">期间总支出</p>
<p class="text-2xl font-bold text-red-600"> <p class="text-2xl font-bold text-red-600">
¥{{ ${{
currentPeriodData.totalExpense.toLocaleString('zh-CN', { currentPeriodData.totalExpense.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
@@ -970,7 +980,7 @@ window.addEventListener('resize', () => {
<div class="text-3xl">📊</div> <div class="text-3xl">📊</div>
<p class="text-sm text-gray-500">平均单笔金额</p> <p class="text-sm text-gray-500">平均单笔金额</p>
<p class="text-2xl font-bold text-blue-600"> <p class="text-2xl font-bold text-blue-600">
¥{{ ${{
currentPeriodData.avgAmount.toLocaleString('zh-CN', { currentPeriodData.avgAmount.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
@@ -986,7 +996,7 @@ window.addEventListener('resize', () => {
<div class="text-3xl">🏆</div> <div class="text-3xl">🏆</div>
<p class="text-sm text-gray-500">最大单笔支出</p> <p class="text-sm text-gray-500">最大单笔支出</p>
<p class="text-2xl font-bold text-orange-600"> <p class="text-2xl font-bold text-orange-600">
¥{{ ${{
currentPeriodData.maxExpense.toLocaleString('zh-CN', { currentPeriodData.maxExpense.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
@@ -1109,7 +1119,7 @@ window.addEventListener('resize', () => {
<div> <div>
<p class="font-semibold">{{ health.categoryName }}</p> <p class="font-semibold">{{ health.categoryName }}</p>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
¥{{ ${{
health.amount.toLocaleString('zh-CN', { health.amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
@@ -1156,7 +1166,7 @@ window.addEventListener('resize', () => {
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-lg font-bold text-red-600"> <p class="text-lg font-bold text-red-600">
-¥{{ -${{
anomaly.amount.toLocaleString('zh-CN', { anomaly.amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
@@ -1208,7 +1218,7 @@ window.addEventListener('resize', () => {
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'income'"> <template v-if="column.key === 'income'">
<span class="font-semibold text-green-600"> <span class="font-semibold text-green-600">
¥{{ ${{
record.income.toLocaleString('zh-CN', { record.income.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
@@ -1217,7 +1227,7 @@ window.addEventListener('resize', () => {
</template> </template>
<template v-else-if="column.key === 'expense'"> <template v-else-if="column.key === 'expense'">
<span class="font-semibold text-red-600"> <span class="font-semibold text-red-600">
¥{{ ${{
record.expense.toLocaleString('zh-CN', { record.expense.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
@@ -1229,7 +1239,7 @@ window.addEventListener('resize', () => {
:class="record.net >= 0 ? 'text-green-600' : 'text-red-600'" :class="record.net >= 0 ? 'text-green-600' : 'text-red-600'"
class="font-bold" class="font-bold"
> >
{{ record.net >= 0 ? '+' : '' }}¥{{ {{ record.net >= 0 ? '+' : '' }}${{
record.net.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) record.net.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
}} }}
</span> </span>
@@ -1265,7 +1275,7 @@ window.addEventListener('resize', () => {
> >
<div v-if="record[column.dataIndex]"> <div v-if="record[column.dataIndex]">
<div class="font-semibold"> <div class="font-semibold">
¥{{ ${{
record[column.dataIndex].amount.toLocaleString('zh-CN', { record[column.dataIndex].amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })
@@ -1279,7 +1289,7 @@ window.addEventListener('resize', () => {
</template> </template>
<template v-else-if="column.key === 'total'"> <template v-else-if="column.key === 'total'">
<span class="font-bold text-red-600"> <span class="font-bold text-red-600">
¥{{ ${{
record.total.toLocaleString('zh-CN', { record.total.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
}) })

View File

@@ -1,38 +1,65 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Button, Card, Input, Tag } from 'ant-design-vue';
defineOptions({ name: 'TaxManagement' });
// 税务统计(空数据)
const taxStats = ref({
yearlyIncome: 0,
paidTax: 0,
potentialSaving: 0,
filingStatus: 'pending',
});
// 节税建议(空数据)
const taxTips = ref([]);
</script>
<template> <template>
<div class="p-6"> <div class="p-6">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">🧾 税务管理</h1> <h1 class="mb-2 text-3xl font-bold text-gray-900">🧾 税务管理</h1>
<p class="text-gray-600">个人所得税计算申报和税务优化建议</p> <p class="text-gray-600">个人所得税计算申报和税务优化建议</p>
</div> </div>
<!-- 税务概览 --> <!-- 税务概览 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
<Card class="text-center"> <Card class="text-center">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl">💰</div> <div class="text-3xl">💰</div>
<p class="text-sm text-gray-500">年度收入</p> <p class="text-sm text-gray-500">年度收入</p>
<p class="text-2xl font-bold text-blue-600">¥{{ taxStats.yearlyIncome.toLocaleString() }}</p> <p class="text-2xl font-bold text-blue-600">
${{ taxStats.yearlyIncome.toLocaleString() }}
</p>
</div> </div>
</Card> </Card>
<Card class="text-center"> <Card class="text-center">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl">🧾</div> <div class="text-3xl">🧾</div>
<p class="text-sm text-gray-500">已缴税额</p> <p class="text-sm text-gray-500">已缴税额</p>
<p class="text-2xl font-bold text-red-600">¥{{ taxStats.paidTax.toLocaleString() }}</p> <p class="text-2xl font-bold text-red-600">
${{ taxStats.paidTax.toLocaleString() }}
</p>
</div> </div>
</Card> </Card>
<Card class="text-center"> <Card class="text-center">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl">💡</div> <div class="text-3xl">💡</div>
<p class="text-sm text-gray-500">可节税</p> <p class="text-sm text-gray-500">可节税</p>
<p class="text-2xl font-bold text-green-600">¥{{ taxStats.potentialSaving.toLocaleString() }}</p> <p class="text-2xl font-bold text-green-600">
${{ taxStats.potentialSaving.toLocaleString() }}
</p>
</div> </div>
</Card> </Card>
<Card class="text-center"> <Card class="text-center">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl">📅</div> <div class="text-3xl">📅</div>
<p class="text-sm text-gray-500">申报状态</p> <p class="text-sm text-gray-500">申报状态</p>
<Tag :color="taxStats.filingStatus === 'completed' ? 'green' : 'orange'"> <Tag
:color="taxStats.filingStatus === 'completed' ? 'green' : 'orange'"
>
{{ taxStats.filingStatus === 'completed' ? '已申报' : '待申报' }} {{ taxStats.filingStatus === 'completed' ? '已申报' : '待申报' }}
</Tag> </Tag>
</div> </div>
@@ -40,7 +67,7 @@
</div> </div>
<!-- 税务工具 --> <!-- 税务工具 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6"> <div class="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card title="🧮 个税计算器"> <Card title="🧮 个税计算器">
<div class="space-y-4"> <div class="space-y-4">
<Input placeholder="月收入" /> <Input placeholder="月收入" />
@@ -51,21 +78,27 @@
</Card> </Card>
<Card title="📊 纳税分析"> <Card title="📊 纳税分析">
<div class="h-48 bg-gray-50 rounded-lg flex items-center justify-center"> <div
class="flex h-48 items-center justify-center rounded-lg bg-gray-50"
>
<div class="text-center"> <div class="text-center">
<div class="text-3xl mb-2">📈</div> <div class="mb-2 text-3xl">📈</div>
<p class="text-gray-600">税负分析图</p> <p class="text-gray-600">税负分析图</p>
</div> </div>
</div> </div>
</Card> </Card>
<Card title="💡 节税建议"> <Card title="💡 节税建议">
<div v-if="taxTips.length === 0" class="text-center py-6"> <div v-if="taxTips.length === 0" class="py-6 text-center">
<div class="text-3xl mb-2">💡</div> <div class="mb-2 text-3xl">💡</div>
<p class="text-gray-500">暂无节税建议</p> <p class="text-gray-500">暂无节税建议</p>
</div> </div>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div v-for="tip in taxTips" :key="tip.id" class="p-3 bg-blue-50 rounded-lg"> <div
v-for="tip in taxTips"
:key="tip.id"
class="rounded-lg bg-blue-50 p-3"
>
<p class="text-sm font-medium text-blue-800">{{ tip.title }}</p> <p class="text-sm font-medium text-blue-800">{{ tip.title }}</p>
<p class="text-xs text-blue-600">{{ tip.description }}</p> <p class="text-xs text-blue-600">{{ tip.description }}</p>
</div> </div>
@@ -75,24 +108,8 @@
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue';
import { Card, Tag, Input, Button } from 'ant-design-vue';
defineOptions({ name: 'TaxManagement' });
// 税务统计(空数据)
const taxStats = ref({
yearlyIncome: 0,
paidTax: 0,
potentialSaving: 0,
filingStatus: 'pending'
});
// 节税建议(空数据)
const taxTips = ref([]);
</script>
<style scoped> <style scoped>
.grid { display: grid; } .grid {
display: grid;
}
</style> </style>

View File

@@ -1,31 +1,130 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Button, Card, Input, Select } from 'ant-design-vue';
defineOptions({ name: 'FinanceTools' });
// 贷款计算器表单
const loanForm = ref({
amount: '',
rate: '',
years: '',
});
const loanResult = ref({
monthlyPayment: null,
});
// 投资计算器表单
const investmentForm = ref({
initial: '',
rate: '',
years: '',
});
const investmentResult = ref({
finalValue: null,
});
// 汇率换算表单
const currencyForm = ref({
amount: '',
from: 'CNY',
to: 'USD',
});
const currencyResult = ref({
converted: null,
});
// 计算方法
const calculateLoan = () => {
const amount = Number.parseFloat(loanForm.value.amount);
const rate = Number.parseFloat(loanForm.value.rate) / 100 / 12;
const months = Number.parseInt(loanForm.value.years) * 12;
if (amount && rate && months) {
const monthlyPayment =
(amount * rate * (1 + rate) ** months) / ((1 + rate) ** months - 1);
loanResult.value.monthlyPayment = monthlyPayment;
}
};
const calculateInvestment = () => {
const initial = Number.parseFloat(investmentForm.value.initial);
const rate = Number.parseFloat(investmentForm.value.rate) / 100;
const years = Number.parseInt(investmentForm.value.years);
if (initial && rate && years) {
const finalValue = initial * (1 + rate) ** years;
investmentResult.value.finalValue = finalValue;
}
};
const convertCurrency = () => {
const amount = Number.parseFloat(currencyForm.value.amount);
// 模拟汇率实际应用中应该调用汇率API
const rate =
currencyForm.value.from === 'CNY' && currencyForm.value.to === 'USD'
? 0.14
: 7.15;
if (amount) {
currencyResult.value.converted = (amount * rate).toFixed(2);
}
};
</script>
<template> <template>
<div class="p-6"> <div class="p-6">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">🛠 财务工具</h1> <h1 class="mb-2 text-3xl font-bold text-gray-900">🛠 财务工具</h1>
<p class="text-gray-600">实用的财务计算和分析工具</p> <p class="text-gray-600">实用的财务计算和分析工具</p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card title="🏠 贷款计算器"> <Card title="🏠 贷款计算器">
<div class="space-y-4"> <div class="space-y-4">
<Input v-model:value="loanForm.amount" placeholder="请输入贷款金额" /> <Input v-model:value="loanForm.amount" placeholder="请输入贷款金额" />
<Input v-model:value="loanForm.rate" placeholder="请输入年利率 %" /> <Input v-model:value="loanForm.rate" placeholder="请输入年利率 %" />
<Input v-model:value="loanForm.years" placeholder="请输入贷款年限" /> <Input v-model:value="loanForm.years" placeholder="请输入贷款年限" />
<Button type="primary" block @click="calculateLoan">计算月供</Button> <Button type="primary" block @click="calculateLoan">计算月供</Button>
<div v-if="loanResult.monthlyPayment" class="mt-4 p-3 bg-blue-50 rounded-lg text-center"> <div
<p class="font-medium text-blue-800">月供¥{{ loanResult.monthlyPayment.toLocaleString() }}</p> v-if="loanResult.monthlyPayment"
class="mt-4 rounded-lg bg-blue-50 p-3 text-center"
>
<p class="font-medium text-blue-800">
月供${{ loanResult.monthlyPayment.toLocaleString() }}
</p>
</div> </div>
</div> </div>
</Card> </Card>
<Card title="📈 投资计算器"> <Card title="📈 投资计算器">
<div class="space-y-4"> <div class="space-y-4">
<Input v-model:value="investmentForm.initial" placeholder="请输入初始投资金额" /> <Input
<Input v-model:value="investmentForm.rate" placeholder="请输入年收益率 %" /> v-model:value="investmentForm.initial"
<Input v-model:value="investmentForm.years" placeholder="请输入投资期限(年)" /> placeholder="请输入初始投资金额"
<Button type="primary" block @click="calculateInvestment">计算收益</Button> />
<div v-if="investmentResult.finalValue" class="mt-4 p-3 bg-green-50 rounded-lg text-center"> <Input
<p class="font-medium text-green-800">预期收益¥{{ investmentResult.finalValue.toLocaleString() }}</p> v-model:value="investmentForm.rate"
placeholder="请输入年收益率 %"
/>
<Input
v-model:value="investmentForm.years"
placeholder="请输入投资期限(年)"
/>
<Button type="primary" block @click="calculateInvestment">
计算收益
</Button>
<div
v-if="investmentResult.finalValue"
class="mt-4 rounded-lg bg-green-50 p-3 text-center"
>
<p class="font-medium text-green-800">
预期收益${{ investmentResult.finalValue.toLocaleString() }}
</p>
</div> </div>
</div> </div>
</Card> </Card>
@@ -33,20 +132,34 @@
<Card title="💱 汇率换算"> <Card title="💱 汇率换算">
<div class="space-y-4"> <div class="space-y-4">
<Input v-model:value="currencyForm.amount" placeholder="请输入金额" /> <Input v-model:value="currencyForm.amount" placeholder="请输入金额" />
<Select v-model:value="currencyForm.from" placeholder="原币种" style="width: 100%"> <Select
v-model:value="currencyForm.from"
placeholder="原币种"
style="width: 100%"
>
<Select.Option value="CNY">🇨🇳 人民币</Select.Option> <Select.Option value="CNY">🇨🇳 人民币</Select.Option>
<Select.Option value="USD">🇺🇸 美元</Select.Option> <Select.Option value="USD">🇺🇸 美元</Select.Option>
<Select.Option value="EUR">🇪🇺 欧元</Select.Option> <Select.Option value="EUR">🇪🇺 欧元</Select.Option>
</Select> </Select>
<Select v-model:value="currencyForm.to" placeholder="目标币种" style="width: 100%"> <Select
v-model:value="currencyForm.to"
placeholder="目标币种"
style="width: 100%"
>
<Select.Option value="USD">🇺🇸 美元</Select.Option> <Select.Option value="USD">🇺🇸 美元</Select.Option>
<Select.Option value="CNY">🇨🇳 人民币</Select.Option> <Select.Option value="CNY">🇨🇳 人民币</Select.Option>
<Select.Option value="EUR">🇪🇺 欧元</Select.Option> <Select.Option value="EUR">🇪🇺 欧元</Select.Option>
</Select> </Select>
<Button type="primary" block @click="convertCurrency">立即换算</Button> <Button type="primary" block @click="convertCurrency">
<div v-if="currencyResult.converted" class="mt-4 p-3 bg-purple-50 rounded-lg text-center"> 立即换算
</Button>
<div
v-if="currencyResult.converted"
class="mt-4 rounded-lg bg-purple-50 p-3 text-center"
>
<p class="font-medium text-purple-800"> <p class="font-medium text-purple-800">
{{ currencyForm.amount }} {{ currencyForm.from }} = {{ currencyResult.converted }} {{ currencyForm.to }} {{ currencyForm.amount }} {{ currencyForm.from }} =
{{ currencyResult.converted }} {{ currencyForm.to }}
</p> </p>
</div> </div>
</div> </div>
@@ -55,79 +168,8 @@
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref } from 'vue';
import { Card, Input, Button, Select } from 'ant-design-vue';
defineOptions({ name: 'FinanceTools' });
// 贷款计算器表单
const loanForm = ref({
amount: '',
rate: '',
years: ''
});
const loanResult = ref({
monthlyPayment: null
});
// 投资计算器表单
const investmentForm = ref({
initial: '',
rate: '',
years: ''
});
const investmentResult = ref({
finalValue: null
});
// 汇率换算表单
const currencyForm = ref({
amount: '',
from: 'CNY',
to: 'USD'
});
const currencyResult = ref({
converted: null
});
// 计算方法
const calculateLoan = () => {
const amount = parseFloat(loanForm.value.amount);
const rate = parseFloat(loanForm.value.rate) / 100 / 12;
const months = parseInt(loanForm.value.years) * 12;
if (amount && rate && months) {
const monthlyPayment = (amount * rate * Math.pow(1 + rate, months)) / (Math.pow(1 + rate, months) - 1);
loanResult.value.monthlyPayment = monthlyPayment;
}
};
const calculateInvestment = () => {
const initial = parseFloat(investmentForm.value.initial);
const rate = parseFloat(investmentForm.value.rate) / 100;
const years = parseInt(investmentForm.value.years);
if (initial && rate && years) {
const finalValue = initial * Math.pow(1 + rate, years);
investmentResult.value.finalValue = finalValue;
}
};
const convertCurrency = () => {
const amount = parseFloat(currencyForm.value.amount);
// 模拟汇率实际应用中应该调用汇率API
const rate = currencyForm.value.from === 'CNY' && currencyForm.value.to === 'USD' ? 0.14 : 7.15;
if (amount) {
currencyResult.value.converted = (amount * rate).toFixed(2);
}
};
</script>
<style scoped> <style scoped>
.grid { display: grid; } .grid {
display: grid;
}
</style> </style>

View File

@@ -1289,7 +1289,7 @@ const _handleAccountChange = (account: string) => {
<div class="text-3xl">📈</div> <div class="text-3xl">📈</div>
<p class="text-sm text-gray-500">总收入</p> <p class="text-sm text-gray-500">总收入</p>
<p class="text-2xl font-bold text-green-600"> <p class="text-2xl font-bold text-green-600">
¥{{ ${{
statistics.totalIncome.toLocaleString('zh-CN', { statistics.totalIncome.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
@@ -1303,7 +1303,7 @@ const _handleAccountChange = (account: string) => {
<div class="text-3xl">📉</div> <div class="text-3xl">📉</div>
<p class="text-sm text-gray-500">总支出</p> <p class="text-sm text-gray-500">总支出</p>
<p class="text-2xl font-bold text-red-600"> <p class="text-2xl font-bold text-red-600">
¥{{ ${{
statistics.totalExpense.toLocaleString('zh-CN', { statistics.totalExpense.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
@@ -1322,7 +1322,7 @@ const _handleAccountChange = (account: string) => {
statistics.netIncome >= 0 ? 'text-green-600' : 'text-red-600' statistics.netIncome >= 0 ? 'text-green-600' : 'text-red-600'
" "
> >
¥{{ ${{
statistics.netIncome.toLocaleString('zh-CN', { statistics.netIncome.toLocaleString('zh-CN', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -1,68 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
"version": "0.2",
"language": "en,en-US",
"allowCompoundWords": true,
"words": [
"acmr",
"antd",
"antdv",
"astro",
"brotli",
"clsx",
"defu",
"demi",
"echarts",
"ependencies",
"esno",
"etag",
"execa",
"iconify",
"iconoir",
"intlify",
"lockb",
"lucide",
"minh",
"minw",
"mkdist",
"mockjs",
"naiveui",
"nocheck",
"noopener",
"noreferrer",
"nprogress",
"nuxt",
"pinia",
"prefixs",
"publint",
"qrcode",
"shadcn",
"sonner",
"sortablejs",
"styl",
"taze",
"ui-kit",
"uicons",
"unplugin",
"unref",
"vben",
"vbenjs",
"vite",
"vitejs",
"vitepress",
"vnode",
"vueuse",
"yxxx"
],
"ignorePaths": [
"**/node_modules/**",
"**/dist/**",
"**/*-dist/**",
"**/icons/**",
"pnpm-lock.yaml",
"**/*.log",
"**/*.test.ts",
"**/*.spec.ts",
"**/__tests__/**"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -48,6 +48,7 @@
"dev:naive": "pnpm -F @vben/web-naive run dev", "dev:naive": "pnpm -F @vben/web-naive run dev",
"dev:play": "pnpm -F @vben/playground run dev", "dev:play": "pnpm -F @vben/playground run dev",
"dev:finance": "pnpm -F @vben/web-finance run dev", "dev:finance": "pnpm -F @vben/web-finance run dev",
"start:finance-mcp": "pnpm -F @vben/finance-mcp-service run start",
"format": "vsh lint --format", "format": "vsh lint --format",
"lint": "vsh lint", "lint": "vsh lint",
"postinstall": "pnpm -r run stub --if-present", "postinstall": "pnpm -r run stub --if-present",

View File

@@ -1,8 +0,0 @@
# 应用标题
VITE_APP_TITLE=Vben Admin
# 应用命名空间用于缓存、store等功能的前缀确保隔离
VITE_APP_NAMESPACE=vben-web-play
# 对store进行加密的密钥在将store持久化到localStorage时会使用该密钥进行加密
VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key

View File

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

View File

@@ -1,20 +0,0 @@
# 端口号
VITE_PORT=5555
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
# 钉钉登录配置
VITE_GLOB_AUTH_DINGDING_CLIENT_ID=应用的clientId
VITE_GLOB_AUTH_DINGDING_CORP_ID=应用的corpId

View File

@@ -1,19 +0,0 @@
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

View File

@@ -1,20 +0,0 @@
import { expect, test } from '@playwright/test';
import { authLogin } from './common/auth';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Auth Login Page Tests', () => {
test('check title and page elements', async ({ page }) => {
// 获取页面标题并断言标题包含 'Vben Admin'
const title = await page.title();
expect(title).toContain('Vben Admin');
});
// 测试用例: 成功登录
test('should successfully login with valid credentials', async ({ page }) => {
await authLogin(page);
});
});

View File

@@ -1,46 +0,0 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export async function authLogin(page: Page) {
// 确保登录表单正常
const usernameInput = await page.locator(`input[name='username']`);
await expect(usernameInput).toBeVisible();
const passwordInput = await page.locator(`input[name='password']`);
await expect(passwordInput).toBeVisible();
const sliderCaptcha = await page.locator(`div[name='captcha']`);
const sliderCaptchaAction = await page.locator(`div[name='captcha-action']`);
await expect(sliderCaptcha).toBeVisible();
await expect(sliderCaptchaAction).toBeVisible();
// 拖动验证码滑块
// 获取拖动按钮的位置
const sliderCaptchaBox = await sliderCaptcha.boundingBox();
if (!sliderCaptchaBox) throw new Error('滑块未找到');
const actionBoundingBox = await sliderCaptchaAction.boundingBox();
if (!actionBoundingBox) throw new Error('要拖动的按钮未找到');
// 计算起始位置和目标位置
const startX = actionBoundingBox.x + actionBoundingBox.width / 2; // div 中心的 x 坐标
const startY = actionBoundingBox.y + actionBoundingBox.height / 2; // div 中心的 y 坐标
const targetX = startX + sliderCaptchaBox.width + actionBoundingBox.width; // 向右拖动容器的宽度
const targetY = startY; // y 坐标保持不变
// 模拟鼠标拖动
await page.mouse.move(startX, startY); // 移动到 action 的中心
await page.mouse.down(); // 按下鼠标
await page.mouse.move(targetX, targetY, { steps: 20 }); // 拖动到目标位置
await page.mouse.up(); // 松开鼠标
// 在拖动后进行断言检查action是否在预期位置,
const newActionBoundingBox = await sliderCaptchaAction.boundingBox();
expect(newActionBoundingBox?.x).toBeGreaterThan(actionBoundingBox.x);
// 到这里已经校验成功,点击进行登录
await page.waitForTimeout(300);
await page.getByRole('button', { name: 'login' }).click();
}

View File

@@ -1,35 +0,0 @@
<!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?d20a01273820422b6aa2ee41b6c9414d';
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>

View File

@@ -1,59 +0,0 @@
{
"name": "@vben/playground",
"version": "5.5.8",
"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": "playground"
},
"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",
"test:e2e": "playwright test",
"test:e2e-ui": "playwright test --ui",
"test:e2e-codegen": "playwright codegen"
},
"imports": {
"#/*": "./src/*"
},
"dependencies": {
"@tanstack/vue-query": "catalog:",
"@vben-core/menu-ui": "workspace:*",
"@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:",
"json-bigint": "catalog:",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
},
"devDependencies": {
"@types/json-bigint": "catalog:"
}
}

View File

@@ -1,108 +0,0 @@
import type { PlaywrightTestConfig } from '@playwright/test';
import { devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
outputDir: 'node_modules/.e2e/test-results/',
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
// {
// name: 'firefox',
// use: {
// ...devices['Desktop Firefox'],
// },
// },
// {
// name: 'webkit',
// use: {
// ...devices['Desktop Safari'],
// },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['list'],
['html', { outputFolder: 'node_modules/.e2e/test-results' }],
],
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
testDir: './__tests__/e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:5555',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'retain-on-failure',
},
/* Run your local dev server before starting the tests */
webServer: {
command: process.env.CI ? 'pnpm preview --port 5555' : 'pnpm dev',
port: 5555,
reuseExistingServer: !process.env.CI,
},
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
};
export default config;

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -1,207 +0,0 @@
/**
* 通用组件共同的使用的基础组件,原先放在 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();
// const publicApi: Recordable<any> = {};
expose(
new Proxy(
{},
{
get: (_target, key) => innerRef.value?.[key],
has: (_target, key) => key in (innerRef.value || {}),
},
),
);
// const instance = getCurrentInstance();
// instance?.proxy?.$nextTick(() => {
// for (const key in innerRef.value) {
// if (typeof innerRef.value[key] === 'function') {
// publicApi[key] = innerRef.value[key];
// }
// }
// });
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, 'select', {
component: Select,
loadingSlot: 'suffixIcon',
modelPropName: 'value',
visibleEvent: 'onVisibleChange',
}),
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, '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

@@ -1,47 +0,0 @@
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

@@ -1,297 +0,0 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { Recordable } from '@vben/types';
import type { ComponentType } from './component';
import { h } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $te } from '@vben/locales';
import {
setupVbenVxeTable,
useVbenVxeGrid as useGrid,
} from '@vben/plugins/vxe-table';
import { get, isFunction, isString } from '@vben/utils';
import { objectOmit } from '@vueuse/core';
import { Button, Image, Popconfirm, Switch, Tag } from 'ant-design-vue';
import { $t } from '#/locales';
import { useVbenForm } from './form';
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
border: false,
columnConfig: {
resizable: true,
},
formConfig: {
// 全局禁用vxe-table的表单配置使用formOptions
enabled: false,
},
minHeight: 180,
proxyConfig: {
autoLoad: true,
response: {
result: 'items',
total: 'total',
list: '',
},
showActiveMsg: true,
showResponseMsg: false,
},
round: true,
showOverflow: true,
size: 'small',
} as VxeTableGridOptions,
});
/**
* 解决vxeTable在热更新时可能会出错的问题
*/
vxeUI.renderer.forEach((_item, key) => {
if (key.startsWith('Cell')) {
vxeUI.renderer.delete(key);
}
});
// 表格配置项可以用 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 },
);
},
});
// 单元格渲染: Tag
vxeUI.renderer.add('CellTag', {
renderTableDefault({ options, props }, { column, row }) {
const value = get(row, column.field);
const tagOptions = options ?? [
{ color: 'success', label: $t('common.enabled'), value: 1 },
{ color: 'error', label: $t('common.disabled'), value: 0 },
];
const tagItem = tagOptions.find((item) => item.value === value);
return h(
Tag,
{
...props,
...objectOmit(tagItem ?? {}, ['label']),
},
{ default: () => tagItem?.label ?? value },
);
},
});
vxeUI.renderer.add('CellSwitch', {
renderTableDefault({ attrs, props }, { column, row }) {
const loadingKey = `__loading_${column.field}`;
const finallyProps = {
checkedChildren: $t('common.enabled'),
checkedValue: 1,
unCheckedChildren: $t('common.disabled'),
unCheckedValue: 0,
...props,
checked: row[column.field],
loading: row[loadingKey] ?? false,
'onUpdate:checked': onChange,
};
async function onChange(newVal: any) {
row[loadingKey] = true;
try {
const result = await attrs?.beforeChange?.(newVal, row);
if (result !== false) {
row[column.field] = newVal;
}
} finally {
row[loadingKey] = false;
}
}
return h(Switch, finallyProps);
},
});
/**
* 注册表格的操作按钮渲染器
*/
vxeUI.renderer.add('CellOperation', {
renderTableDefault({ attrs, options, props }, { column, row }) {
const defaultProps = { size: 'small', type: 'link', ...props };
let align = 'end';
switch (column.align) {
case 'center': {
align = 'center';
break;
}
case 'left': {
align = 'start';
break;
}
default: {
align = 'end';
break;
}
}
const presets: Recordable<Recordable<any>> = {
delete: {
danger: true,
text: $t('common.delete'),
},
edit: {
text: $t('common.edit'),
},
};
const operations: Array<Recordable<any>> = (
options || ['edit', 'delete']
)
.map((opt) => {
if (isString(opt)) {
return presets[opt]
? { code: opt, ...presets[opt], ...defaultProps }
: {
code: opt,
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
...defaultProps,
};
} else {
return { ...defaultProps, ...presets[opt.code], ...opt };
}
})
.map((opt) => {
const optBtn: Recordable<any> = {};
Object.keys(opt).forEach((key) => {
optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
});
return optBtn;
})
.filter((opt) => opt.show !== false);
function renderBtn(opt: Recordable<any>, listen = true) {
return h(
Button,
{
...props,
...opt,
icon: undefined,
onClick: listen
? () =>
attrs?.onClick?.({
code: opt.code,
row,
})
: undefined,
},
{
default: () => {
const content = [];
if (opt.icon) {
content.push(
h(IconifyIcon, { class: 'size-5', icon: opt.icon }),
);
}
content.push(opt.text);
return content;
},
},
);
}
function renderConfirm(opt: Recordable<any>) {
let viewportWrapper: HTMLElement | null = null;
return h(
Popconfirm,
{
/**
* 当popconfirm用在固定列中时将固定列作为弹窗的容器时可能会因为固定列较窄而无法容纳弹窗
* 将表格主体区域作为弹窗容器时又会因为固定列的层级较高而遮挡弹窗
* 将body或者表格视口区域作为弹窗容器时又会导致弹窗无法跟随表格滚动。
* 鉴于以上各种情况,一种折中的解决方案是弹出层展示时,禁止操作表格的滚动条。
* 这样既解决了弹窗的遮挡问题,又不至于让弹窗随着表格的滚动而跑出视口区域。
*/
getPopupContainer(el) {
viewportWrapper = el.closest('.vxe-table--viewport-wrapper');
return document.body;
},
placement: 'topLeft',
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
...props,
...opt,
icon: undefined,
onOpenChange: (open: boolean) => {
// 当弹窗打开时,禁止表格的滚动
if (open) {
viewportWrapper?.style.setProperty('pointer-events', 'none');
} else {
viewportWrapper?.style.removeProperty('pointer-events');
}
},
onConfirm: () => {
attrs?.onClick?.({
code: opt.code,
row,
});
},
},
{
default: () => renderBtn({ ...opt }, false),
description: () =>
h(
'div',
{ class: 'truncate' },
$t('ui.actionMessage.deleteConfirm', [
row[attrs?.nameField || 'name'],
]),
),
},
);
}
const btns = operations.map((opt) =>
opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt),
);
return h(
'div',
{
class: 'flex table-operations',
style: { justifyContent: align },
},
btns,
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
},
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType>>
) => useGrid<T, ComponentType>(...rest);
export type OnActionClickParams<T = Recordable<any>> = {
code: string;
row: T;
};
export type OnActionClickFn<T = Recordable<any>> = (
params: OnActionClickParams<T>,
) => void;
export type * from '@vben/plugins/vxe-table';

View File

@@ -1,57 +0,0 @@
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, {
withCredentials: true,
});
}
/**
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
null,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', null, {
withCredentials: true,
});
}
/**
* 获取用户权限码
*/
export async function getAccessCodesApi() {
return requestClient.get<string[]>('/auth/codes');
}

View File

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

View File

@@ -1,10 +0,0 @@
import type { RouteRecordStringComponent } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 获取用户所有菜单
*/
export async function getAllMenusApi() {
return requestClient.get<RouteRecordStringComponent[]>('/menu/all');
}

View File

@@ -1,10 +0,0 @@
import type { UserInfo } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 获取用户信息
*/
export async function getUserInfoApi() {
return requestClient.get<UserInfo>('/user/info');
}

View File

@@ -1,28 +0,0 @@
import type { RequestResponse } from '@vben/request';
import { requestClient } from '../request';
/**
* 下载文件获取Blob
* @returns Blob
*/
async function downloadFile1() {
return requestClient.download<Blob>(
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
);
}
/**
* 下载文件获取完整的Response
* @returns RequestResponse<Blob>
*/
async function downloadFile2() {
return requestClient.download<RequestResponse<Blob>>(
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
{
responseReturn: 'raw',
},
);
}
export { downloadFile1, downloadFile2 };

View File

@@ -1,2 +0,0 @@
export * from './status';
export * from './table';

View File

@@ -1,10 +0,0 @@
import { requestClient } from '#/api/request';
/**
* 发起请求
*/
async function getBigIntData() {
return requestClient.get('/demo/bigint');
}
export { getBigIntData };

View File

@@ -1,19 +0,0 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 发起数组请求
*/
async function getParamsData(
params: Recordable<any>,
type: 'brackets' | 'comma' | 'indices' | 'repeat',
) {
return requestClient.get('/status', {
params,
paramsSerializer: type,
responseReturn: 'raw',
});
}
export { getParamsData };

View File

@@ -1,10 +0,0 @@
import { requestClient } from '#/api/request';
/**
* 模拟任意状态码
*/
async function getMockStatusApi(status: string) {
return requestClient.get('/status', { params: { status } });
}
export { getMockStatusApi };

View File

@@ -1,18 +0,0 @@
import { requestClient } from '#/api/request';
export namespace DemoTableApi {
export interface PageFetchParams {
[key: string]: any;
page: number;
pageSize: number;
}
}
/**
* 获取示例表格数据
*/
async function getExampleTableApi(params: DemoTableApi.PageFetchParams) {
return requestClient.get('/table/list', { params });
}
export { getExampleTableApi };

View File

@@ -1,25 +0,0 @@
import { requestClient } from '#/api/request';
interface UploadFileParams {
file: File;
onError?: (error: Error) => void;
onProgress?: (progress: { percent: number }) => void;
onSuccess?: (data: any, file: File) => void;
}
export async function upload_file({
file,
onError,
onProgress,
onSuccess,
}: UploadFileParams) {
try {
onProgress?.({ percent: 0 });
const data = await requestClient.upload('/upload', { file });
onProgress?.({ percent: 100 });
onSuccess?.(data, file);
} catch (error) {
onError?.(error instanceof Error ? error : new Error(String(error)));
}
}

View File

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

View File

@@ -1,129 +0,0 @@
/**
* 该文件可自行根据业务逻辑进行调整
*/
import type { AxiosResponseHeaders, 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 { cloneDeep } from '@vben/utils';
import { message } from 'ant-design-vue';
import JSONBigInt from 'json-bigint';
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,
transformResponse: (data: any, header: AxiosResponseHeaders) => {
// storeAsString指示将BigInt存储为字符串设为false则会存储为内置的BigInt类型
return header.getContentType()?.toString().includes('application/json')
? cloneDeep(
JSONBigInt({ storeAsString: true, strict: true }).parse(data),
)
: data;
},
});
/**
* 重新认证逻辑
*/
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 });
export interface PageFetchParams {
[key: string]: any;
pageNo?: number;
pageSize?: number;
}

View File

@@ -1,54 +0,0 @@
import { requestClient } from '#/api/request';
export namespace SystemDeptApi {
export interface SystemDept {
[key: string]: any;
children?: SystemDept[];
id: string;
name: string;
remark?: string;
status: 0 | 1;
}
}
/**
* 获取部门列表数据
*/
async function getDeptList() {
return requestClient.get<Array<SystemDeptApi.SystemDept>>(
'/system/dept/list',
);
}
/**
* 创建部门
* @param data 部门数据
*/
async function createDept(
data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
) {
return requestClient.post('/system/dept', data);
}
/**
* 更新部门
*
* @param id 部门 ID
* @param data 部门数据
*/
async function updateDept(
id: string,
data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
) {
return requestClient.put(`/system/dept/${id}`, data);
}
/**
* 删除部门
* @param id 部门 ID
*/
async function deleteDept(id: string) {
return requestClient.delete(`/system/dept/${id}`);
}
export { createDept, deleteDept, getDeptList, updateDept };

View File

@@ -1,3 +0,0 @@
export * from './dept';
export * from './menu';
export * from './role';

View File

@@ -1,158 +0,0 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemMenuApi {
/** 徽标颜色集合 */
export const BadgeVariants = [
'default',
'destructive',
'primary',
'success',
'warning',
] as const;
/** 徽标类型集合 */
export const BadgeTypes = ['dot', 'normal'] as const;
/** 菜单类型集合 */
export const MenuTypes = [
'catalog',
'menu',
'embedded',
'link',
'button',
] as const;
/** 系统菜单 */
export interface SystemMenu {
[key: string]: any;
/** 后端权限标识 */
authCode: string;
/** 子级 */
children?: SystemMenu[];
/** 组件 */
component?: string;
/** 菜单ID */
id: string;
/** 菜单元数据 */
meta?: {
/** 激活时显示的图标 */
activeIcon?: string;
/** 作为路由时需要激活的菜单的Path */
activePath?: string;
/** 固定在标签栏 */
affixTab?: boolean;
/** 在标签栏固定的顺序 */
affixTabOrder?: number;
/** 徽标内容(当徽标类型为normal时有效) */
badge?: string;
/** 徽标类型 */
badgeType?: (typeof BadgeTypes)[number];
/** 徽标颜色 */
badgeVariants?: (typeof BadgeVariants)[number];
/** 在菜单中隐藏下级 */
hideChildrenInMenu?: boolean;
/** 在面包屑中隐藏 */
hideInBreadcrumb?: boolean;
/** 在菜单中隐藏 */
hideInMenu?: boolean;
/** 在标签栏中隐藏 */
hideInTab?: boolean;
/** 菜单图标 */
icon?: string;
/** 内嵌Iframe的URL */
iframeSrc?: string;
/** 是否缓存页面 */
keepAlive?: boolean;
/** 外链页面的URL */
link?: string;
/** 同一个路由最大打开的标签数 */
maxNumOfOpenTab?: number;
/** 无需基础布局 */
noBasicLayout?: boolean;
/** 是否在新窗口打开 */
openInNewWindow?: boolean;
/** 菜单排序 */
order?: number;
/** 额外的路由参数 */
query?: Recordable<any>;
/** 菜单标题 */
title?: string;
};
/** 菜单名称 */
name: string;
/** 路由路径 */
path: string;
/** 父级ID */
pid: string;
/** 重定向 */
redirect?: string;
/** 菜单类型 */
type: (typeof MenuTypes)[number];
}
}
/**
* 获取菜单数据列表
*/
async function getMenuList() {
return requestClient.get<Array<SystemMenuApi.SystemMenu>>(
'/system/menu/list',
);
}
async function isMenuNameExists(
name: string,
id?: SystemMenuApi.SystemMenu['id'],
) {
return requestClient.get<boolean>('/system/menu/name-exists', {
params: { id, name },
});
}
async function isMenuPathExists(
path: string,
id?: SystemMenuApi.SystemMenu['id'],
) {
return requestClient.get<boolean>('/system/menu/path-exists', {
params: { id, path },
});
}
/**
* 创建菜单
* @param data 菜单数据
*/
async function createMenu(
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
) {
return requestClient.post('/system/menu', data);
}
/**
* 更新菜单
*
* @param id 菜单 ID
* @param data 菜单数据
*/
async function updateMenu(
id: string,
data: Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>,
) {
return requestClient.put(`/system/menu/${id}`, data);
}
/**
* 删除菜单
* @param id 菜单 ID
*/
async function deleteMenu(id: string) {
return requestClient.delete(`/system/menu/${id}`);
}
export {
createMenu,
deleteMenu,
getMenuList,
isMenuNameExists,
isMenuPathExists,
updateMenu,
};

View File

@@ -1,55 +0,0 @@
import type { Recordable } from '@vben/types';
import { requestClient } from '#/api/request';
export namespace SystemRoleApi {
export interface SystemRole {
[key: string]: any;
id: string;
name: string;
permissions: string[];
remark?: string;
status: 0 | 1;
}
}
/**
* 获取角色列表数据
*/
async function getRoleList(params: Recordable<any>) {
return requestClient.get<Array<SystemRoleApi.SystemRole>>(
'/system/role/list',
{ params },
);
}
/**
* 创建角色
* @param data 角色数据
*/
async function createRole(data: Omit<SystemRoleApi.SystemRole, 'id'>) {
return requestClient.post('/system/role', data);
}
/**
* 更新角色
*
* @param id 角色 ID
* @param data 角色数据
*/
async function updateRole(
id: string,
data: Omit<SystemRoleApi.SystemRole, 'id'>,
) {
return requestClient.put(`/system/role/${id}`, data);
}
/**
* 删除角色
* @param id 角色 ID
*/
async function deleteRole(id: string) {
return requestClient.delete(`/system/role/${id}`);
}
export { createRole, deleteRole, getRoleList, updateRole };

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