diff --git a/.changeset/README.md b/.changeset/README.md deleted file mode 100644 index 5654e898..00000000 --- a/.changeset/README.md +++ /dev/null @@ -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) diff --git a/.changeset/config.json b/.changeset/config.json deleted file mode 100644 index f954fb4b..00000000 --- a/.changeset/config.json +++ /dev/null @@ -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": [] -} diff --git a/LICENSE b/LICENSE deleted file mode 100644 index cec5b427..00000000 --- a/LICENSE +++ /dev/null @@ -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. diff --git a/README.ja-JP.md b/README.ja-JP.md deleted file mode 100644 index f7847a1d..00000000 --- a/README.ja-JP.md +++ /dev/null @@ -1,153 +0,0 @@ -
- - VbenAdmin Logo - -
-
- -[![license](https://img.shields.io/github/license/anncwb/vue-vben-admin.svg)](LICENSE) - -

Vue Vben Admin

-
- -[![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 - -
- VbenAdmin Logo - VbenAdmin Logo - VbenAdmin Logo -
- -### Gitpodを使用 - -Gitpod(GitHub用の無料オンライン開発環境)でプロジェクトを開き、すぐにコーディングを開始します。 - -[![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はサポートしません - -| [Edge](http://godban.github.io/browsers-support-badges/)
Edge | [Firefox](http://godban.github.io/browsers-support-badges/)
Firefox | [Chrome](http://godban.github.io/browsers-support-badges/)
Chrome | [Safari](http://godban.github.io/browsers-support-badges/)
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) - -Paypal Me - -## 貢献者 - - - Contributors - - -## Discord - -- [Github Discussions](https://github.com/anncwb/vue-vben-admin/discussions) - -## ライセンス - -[MIT © Vben-2020](./LICENSE) diff --git a/analytics-complete-success.png b/analytics-complete-success.png deleted file mode 100644 index a56a68d1..00000000 Binary files a/analytics-complete-success.png and /dev/null differ diff --git a/analytics-debug.png b/analytics-debug.png deleted file mode 100644 index a56a68d1..00000000 Binary files a/analytics-debug.png and /dev/null differ diff --git a/analytics-overview.png b/analytics-overview.png deleted file mode 100644 index a56a68d1..00000000 Binary files a/analytics-overview.png and /dev/null differ diff --git a/apps/backend/.env b/apps/backend/.env index b20c4a65..baa073dd 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -1,3 +1,3 @@ -PORT=5320 +PORT=5666 ACCESS_TOKEN_SECRET=access_token_secret REFRESH_TOKEN_SECRET=refresh_token_secret diff --git a/apps/backend/README.md b/apps/backend/README.md index 401bda76..6c54aef0 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -13,3 +13,19 @@ $ pnpm run start # production mode $ pnpm run build ``` + +## Telegram Webhook 集成 + +财务系统新增交易后可自动通知本地的 Telegram 机器人,默认会将交易数据通过以下 Webhook 发送: + +- `http://192.168.9.28:8889/webhook/transaction` +- 认证密钥:`ktapp.cc` + +如需自定义目标地址或密钥,可在运行前设置以下环境变量: + +```bash +export TELEGRAM_WEBHOOK_URL="http://:8889/webhook/transaction" +export TELEGRAM_WEBHOOK_SECRET="自定义密钥" +``` + +也可以使用旧变量 `FINANCE_BOT_WEBHOOK_URL`、`FINANCE_BOT_WEBHOOK_SECRET` 进行兼容配置。 diff --git a/apps/backend/api/finance/media.get.ts b/apps/backend/api/finance/media.get.ts new file mode 100644 index 00000000..1127c368 --- /dev/null +++ b/apps/backend/api/finance/media.get.ts @@ -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); +}); + diff --git a/apps/backend/api/finance/media/[id].get.ts b/apps/backend/api/finance/media/[id].get.ts new file mode 100644 index 00000000..720a2cd1 --- /dev/null +++ b/apps/backend/api/finance/media/[id].get.ts @@ -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); +}); + diff --git a/apps/backend/api/finance/media/[id]/download.get.ts b/apps/backend/api/finance/media/[id]/download.get.ts new file mode 100644 index 00000000..3b35c0fe --- /dev/null +++ b/apps/backend/api/finance/media/[id]/download.get.ts @@ -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)); +}); + diff --git a/apps/backend/api/finance/reimbursements.get.ts b/apps/backend/api/finance/reimbursements.get.ts new file mode 100644 index 00000000..fd1b6ceb --- /dev/null +++ b/apps/backend/api/finance/reimbursements.get.ts @@ -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); +}); diff --git a/apps/backend/api/finance/reimbursements.post.ts b/apps/backend/api/finance/reimbursements.post.ts new file mode 100644 index 00000000..4731615f --- /dev/null +++ b/apps/backend/api/finance/reimbursements.post.ts @@ -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); +}); diff --git a/apps/backend/api/finance/reimbursements/[id].put.ts b/apps/backend/api/finance/reimbursements/[id].put.ts new file mode 100644 index 00000000..93244251 --- /dev/null +++ b/apps/backend/api/finance/reimbursements/[id].put.ts @@ -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 = {}; + + 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); +}); diff --git a/apps/backend/api/finance/transactions.get.ts b/apps/backend/api/finance/transactions.get.ts index 8323ba6d..140598c0 100644 --- a/apps/backend/api/finance/transactions.get.ts +++ b/apps/backend/api/finance/transactions.get.ts @@ -1,11 +1,28 @@ import { getQuery } from 'h3'; -import { fetchTransactions } from '~/utils/finance-repository'; +import { + fetchTransactions, + type TransactionStatus, +} from '~/utils/finance-repository'; import { useResponseSuccess } from '~/utils/response'; export default defineEventHandler(async (event) => { const query = getQuery(event); 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); }); diff --git a/apps/backend/api/finance/transactions.post.ts b/apps/backend/api/finance/transactions.post.ts index c0087e7e..e6243f0f 100644 --- a/apps/backend/api/finance/transactions.post.ts +++ b/apps/backend/api/finance/transactions.post.ts @@ -1,8 +1,19 @@ 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 { notifyTransactionWebhook } from '~/utils/telegram-webhook'; const DEFAULT_CURRENCY = 'CNY'; +const ALLOWED_STATUSES: TransactionStatus[] = [ + 'draft', + 'pending', + 'approved', + 'rejected', + 'paid', +]; export default defineEventHandler(async (event) => { const body = await readBody(event); @@ -16,6 +27,12 @@ export default defineEventHandler(async (event) => { return useResponseError('金额格式不正确', -1); } + const status = + (body.status as TransactionStatus | undefined) ?? 'approved'; + if (!ALLOWED_STATUSES.includes(status)) { + return useResponseError('状态值不合法', -1); + } + const transaction = createTransaction({ type: body.type, amount, @@ -26,7 +43,18 @@ export default defineEventHandler(async (event) => { description: body.description ?? '', project: body.project ?? 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); }); diff --git a/apps/backend/api/finance/transactions/[id].put.ts b/apps/backend/api/finance/transactions/[id].put.ts index d2488497..a2809c75 100644 --- a/apps/backend/api/finance/transactions/[id].put.ts +++ b/apps/backend/api/finance/transactions/[id].put.ts @@ -2,9 +2,18 @@ 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)) { @@ -41,6 +50,31 @@ export default defineEventHandler(async (event) => { 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) { diff --git a/apps/backend/backend.tar.gz b/apps/backend/backend.tar.gz new file mode 100644 index 00000000..421683e3 Binary files /dev/null and b/apps/backend/backend.tar.gz differ diff --git a/apps/backend/scripts/import-finance-data.js b/apps/backend/scripts/import-finance-data.js index 2bac3f86..b71bf095 100644 --- a/apps/backend/scripts/import-finance-data.js +++ b/apps/backend/scripts/import-finance-data.js @@ -42,6 +42,24 @@ fs.mkdirSync(storeDir, { recursive: true }); const dbFile = path.join(storeDir, 'finance.db'); 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.exec(` @@ -106,11 +124,38 @@ db.exec(` project TEXT, memo TEXT, 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, 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 lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0); if (lines.length <= 1) { diff --git a/apps/backend/utils/finance-metadata.ts b/apps/backend/utils/finance-metadata.ts index 5fbb752e..22ab3290 100644 --- a/apps/backend/utils/finance-metadata.ts +++ b/apps/backend/utils/finance-metadata.ts @@ -5,13 +5,39 @@ import { MOCK_CURRENCIES, MOCK_EXCHANGE_RATES, } from './mock-data'; +import db from './sqlite'; export function listAccounts() { return MOCK_ACCOUNTS; } 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() { @@ -27,29 +53,78 @@ export function listExchangeRates() { } export function createCategoryRecord(category: any) { - const newCategory = { - ...category, - id: MOCK_CATEGORIES.length + 1, - createdAt: new Date().toISOString(), - }; - MOCK_CATEGORIES.push(newCategory); - return newCategory; + try { + const stmt = db.prepare(` + INSERT INTO finance_categories (name, type, icon, color, user_id, is_active) + VALUES (?, ?, ?, ?, ?, 1) + `); + const result = stmt.run( + 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) { - const index = MOCK_CATEGORIES.findIndex((c) => c.id === id); - if (index !== -1) { - MOCK_CATEGORIES[index] = { ...MOCK_CATEGORIES[index], ...category }; - return MOCK_CATEGORIES[index]; + try { + const updates: string[] = []; + const params: any[] = []; + + 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) { - const index = MOCK_CATEGORIES.findIndex((c) => c.id === id); - if (index !== -1) { - MOCK_CATEGORIES.splice(index, 1); + try { + // 软删除 + const stmt = db.prepare(` + UPDATE finance_categories + SET is_active = 0 + WHERE id = ? + `); + stmt.run(id); return true; + } catch (error) { + console.error('删除分类失败:', error); + return false; } - return false; } diff --git a/apps/backend/utils/finance-repository.ts b/apps/backend/utils/finance-repository.ts index d4427399..bc52626b 100644 --- a/apps/backend/utils/finance-repository.ts +++ b/apps/backend/utils/finance-repository.ts @@ -16,6 +16,13 @@ interface TransactionRow { project: null | string; memo: null | 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; deleted_at: null | string; } @@ -32,8 +39,22 @@ interface TransactionPayload { memo?: null | string; createdAt?: string; 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) { if (currency === BASE_CURRENCY) { return 1; @@ -49,11 +70,11 @@ function mapTransaction(row: TransactionRow) { return { id: row.id, userId: 1, - type: row.type as 'expense' | 'income' | 'transfer', - amount: row.amount, + type: 'expense' as const, + amount: Math.abs(row.amount), currency: row.currency, exchangeRateToBase: row.exchange_rate_to_base, - amountInBase: row.amount_in_base, + amountInBase: Math.abs(row.amount_in_base), categoryId: row.category_id ?? undefined, accountId: row.account_id ?? undefined, transactionDate: row.transaction_date, @@ -61,13 +82,24 @@ function mapTransaction(row: TransactionRow) { project: row.project ?? undefined, memo: row.memo ?? undefined, 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), deletedAt: row.deleted_at ?? undefined, }; } export function fetchTransactions( - options: { includeDeleted?: boolean; type?: string } = {}, + options: { + includeDeleted?: boolean; + type?: string; + statuses?: TransactionStatus[]; + } = {}, ) { const clauses: string[] = []; const params: Record = {}; @@ -79,11 +111,19 @@ export function fetchTransactions( clauses.push('type = @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 stmt = db.prepare( - `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); @@ -91,7 +131,7 @@ export function fetchTransactions( export function getTransactionById(id: number) { const stmt = db.prepare( - `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); return row ? mapTransaction(row) : null; @@ -104,9 +144,20 @@ export function createTransaction(payload: TransactionPayload) { payload.createdAt && payload.createdAt.length > 0 ? payload.createdAt : 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( - `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({ @@ -122,6 +173,13 @@ export function createTransaction(payload: TransactionPayload) { project: payload.project ?? null, memo: payload.memo ?? null, 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)); @@ -133,6 +191,25 @@ export function updateTransaction(id: number, payload: TransactionPayload) { 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 = { type: payload.type ?? current.type, amount: payload.amount ?? current.amount, @@ -144,13 +221,21 @@ export function updateTransaction(id: number, payload: TransactionPayload) { project: payload.project ?? current.project ?? null, memo: payload.memo ?? current.memo ?? null, 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 amountInBase = +(next.amount * exchangeRate).toFixed(2); 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; @@ -168,6 +253,13 @@ export function updateTransaction(id: number, payload: TransactionPayload) { description: next.description, project: next.project, 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, deletedAt, }); @@ -203,12 +295,20 @@ export function replaceAllTransactions( project?: null | string; transactionDate: 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(); 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( @@ -220,15 +320,36 @@ export function replaceAllTransactions( const row = getRate.get(item.currency) as undefined | { rate: number }; const rate = row?.rate ?? 1; 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({ ...item, exchangeRateToBase: rate, amountInBase, project: item.project ?? null, memo: item.memo ?? null, - createdAt: - item.createdAt ?? - new Date(`${item.transactionDate}T00:00:00Z`).toISOString(), + createdAt, + status, + 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, }); } }); diff --git a/apps/backend/utils/media-repository.ts b/apps/backend/utils/media-repository.ts new file mode 100644 index 00000000..acf01926 --- /dev/null +++ b/apps/backend/utils/media-repository.ts @@ -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 = {}; + + 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( + `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( + `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; +} + diff --git a/apps/backend/utils/media-storage.ts b/apps/backend/utils/media-storage.ts new file mode 100644 index 00000000..f617a5ee --- /dev/null +++ b/apps/backend/utils/media-storage.ts @@ -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); +} + diff --git a/apps/backend/utils/mock-data.ts b/apps/backend/utils/mock-data.ts index 38cd3e27..a52eccca 100644 --- a/apps/backend/utils/mock-data.ts +++ b/apps/backend/utils/mock-data.ts @@ -693,11 +693,11 @@ export interface Category { } export const MOCK_CATEGORIES: Category[] = [ - // 支出分类 + // 支出分类 (ID 1-17) { id: 1, userId: null, - name: '餐饮', + name: '餐饮美食', type: 'expense', icon: '🍜', color: '#ff6b6b', @@ -708,9 +708,9 @@ export const MOCK_CATEGORIES: Category[] = [ { id: 2, userId: null, - name: '交通', + name: '佣金/返佣', type: 'expense', - icon: '🚗', + icon: '💸', color: '#4ecdc4', sortOrder: 2, isSystem: true, @@ -719,9 +719,9 @@ export const MOCK_CATEGORIES: Category[] = [ { id: 3, userId: null, - name: '购物', + name: '分红', type: 'expense', - icon: '🛍️', + icon: '💰', color: '#95e1d3', sortOrder: 3, isSystem: true, @@ -730,9 +730,9 @@ export const MOCK_CATEGORIES: Category[] = [ { id: 4, userId: null, - name: '娱乐', + name: '技术/软件', type: 'expense', - icon: '🎮', + icon: '💻', color: '#f38181', sortOrder: 4, isSystem: true, @@ -741,9 +741,9 @@ export const MOCK_CATEGORIES: Category[] = [ { id: 5, userId: null, - name: '软件订阅', + name: '固定资产', type: 'expense', - icon: '💻', + icon: '🏠', color: '#aa96da', sortOrder: 5, isSystem: true, @@ -752,9 +752,9 @@ export const MOCK_CATEGORIES: Category[] = [ { id: 6, userId: null, - name: '投资支出', + name: '退款', type: 'expense', - icon: '📊', + icon: '↩️', color: '#fcbad3', sortOrder: 6, isSystem: true, @@ -763,9 +763,9 @@ export const MOCK_CATEGORIES: Category[] = [ { id: 7, userId: null, - name: '医疗健康', + name: '服务器/技术', type: 'expense', - icon: '🏥', + icon: '🖥️', color: '#a8d8ea', sortOrder: 7, isSystem: true, @@ -774,9 +774,9 @@ export const MOCK_CATEGORIES: Category[] = [ { id: 8, userId: null, - name: '房租房贷', + name: '工资', type: 'expense', - icon: '🏠', + icon: '💼', color: '#ffcccc', sortOrder: 8, isSystem: true, @@ -785,9 +785,9 @@ export const MOCK_CATEGORIES: Category[] = [ { id: 9, userId: null, - name: '教育', + name: '借款/转账', type: 'expense', - icon: '📚', + icon: '🔄', color: '#ffd3b6', sortOrder: 9, isSystem: true, @@ -796,29 +796,106 @@ export const MOCK_CATEGORIES: Category[] = [ { id: 10, 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: '其他支出', type: 'expense', icon: '📝', - color: '#dfe4ea', + color: '#b2bec3', sortOrder: 99, isSystem: true, isActive: true, }, - // 收入分类 + // 收入分类 (ID 18-23) { - id: 11, + id: 18, userId: null, - name: '工资', + name: '工资收入', type: 'income', - icon: '💼', + icon: '💵', color: '#38ada9', sortOrder: 1, isSystem: true, isActive: true, }, { - id: 12, + id: 19, userId: null, name: '奖金', type: 'income', @@ -829,7 +906,7 @@ export const MOCK_CATEGORIES: Category[] = [ isActive: true, }, { - id: 13, + id: 20, userId: null, name: '投资收益', type: 'income', @@ -840,7 +917,7 @@ export const MOCK_CATEGORIES: Category[] = [ isActive: true, }, { - id: 14, + id: 21, userId: null, name: '副业收入', type: 'income', @@ -851,12 +928,23 @@ export const MOCK_CATEGORIES: Category[] = [ isActive: true, }, { - id: 15, + id: 22, + userId: null, + name: '退款收入', + type: 'income', + icon: '↩️', + color: '#82ccdd', + sortOrder: 5, + isSystem: true, + isActive: true, + }, + { + id: 23, userId: null, name: '其他收入', type: 'income', icon: '💰', - color: '#82ccdd', + color: '#10ac84', sortOrder: 99, isSystem: true, isActive: true, diff --git a/apps/backend/utils/sqlite.ts b/apps/backend/utils/sqlite.ts index 39191230..1cb61af6 100644 --- a/apps/backend/utils/sqlite.ts +++ b/apps/backend/utils/sqlite.ts @@ -9,6 +9,24 @@ mkdirSync(dirname(dbFile), { recursive: true }); 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.exec(` @@ -72,6 +90,13 @@ database.exec(` project TEXT, memo TEXT, 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, deleted_at TEXT, 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; diff --git a/apps/backend/utils/telegram-webhook.ts b/apps/backend/utils/telegram-webhook.ts new file mode 100644 index 00000000..e19dee4e --- /dev/null +++ b/apps/backend/utils/telegram-webhook.ts @@ -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); +} diff --git a/apps/finance-mcp-service/README.md b/apps/finance-mcp-service/README.md new file mode 100644 index 00000000..7900f6be --- /dev/null +++ b/apps/finance-mcp-service/README.md @@ -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`。 diff --git a/apps/finance-mcp-service/package.json b/apps/finance-mcp-service/package.json new file mode 100644 index 00000000..56157845 --- /dev/null +++ b/apps/finance-mcp-service/package.json @@ -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": {} +} diff --git a/apps/finance-mcp-service/pnpm-lock.yaml b/apps/finance-mcp-service/pnpm-lock.yaml new file mode 100644 index 00000000..9b60ae17 --- /dev/null +++ b/apps/finance-mcp-service/pnpm-lock.yaml @@ -0,0 +1,9 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} diff --git a/apps/finance-mcp-service/src/finance-client.js b/apps/finance-mcp-service/src/finance-client.js new file mode 100644 index 00000000..59f39e37 --- /dev/null +++ b/apps/finance-mcp-service/src/finance-client.js @@ -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'); diff --git a/apps/finance-mcp-service/src/index.js b/apps/finance-mcp-service/src/index.js new file mode 100644 index 00000000..a1e4c15b --- /dev/null +++ b/apps/finance-mcp-service/src/index.js @@ -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(); diff --git a/apps/web-antd/.env.production b/apps/web-antd/.env.production index 5375847a..82b18107 100644 --- a/apps/web-antd/.env.production +++ b/apps/web-antd/.env.production @@ -1,7 +1,7 @@ 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 VITE_COMPRESS=none diff --git a/apps/web-antd/apps/web-antd/src/views/finance/reimbursement/detail.vue b/apps/web-antd/apps/web-antd/src/views/finance/reimbursement/detail.vue new file mode 100644 index 00000000..30a4cd72 --- /dev/null +++ b/apps/web-antd/apps/web-antd/src/views/finance/reimbursement/detail.vue @@ -0,0 +1,719 @@ + + + + + diff --git a/apps/web-antd/apps/web-antd/src/views/finance/reimbursement/index.vue b/apps/web-antd/apps/web-antd/src/views/finance/reimbursement/index.vue new file mode 100644 index 00000000..60ed72e9 --- /dev/null +++ b/apps/web-antd/apps/web-antd/src/views/finance/reimbursement/index.vue @@ -0,0 +1,780 @@ + + + + + diff --git a/apps/web-antd/src/api/core/finance.ts b/apps/web-antd/src/api/core/finance.ts index 915de53f..c599400f 100644 --- a/apps/web-antd/src/api/core/finance.ts +++ b/apps/web-antd/src/api/core/finance.ts @@ -1,6 +1,13 @@ import { requestClient } from '../request'; export namespace FinanceApi { + export type TransactionStatus = + | 'draft' + | 'pending' + | 'approved' + | 'rejected' + | 'paid'; + // 货币类型 export interface Currency { code: string; @@ -71,6 +78,38 @@ export namespace FinanceApi { createdAt: string; isDeleted?: boolean; 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; memo?: 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?: { type?: 'expense' | 'income' | 'transfer'; + statuses?: TransactionStatus[]; + includeDeleted?: boolean; }) { + const query: Record = {}; + 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('/finance/transactions', { - params, + params: query, }); } @@ -233,6 +312,66 @@ export namespace FinanceApi { return requestClient.put(`/finance/transactions/${id}`, data); } + /** + * 获取报销申请 + */ + export async function getReimbursements(params?: { + type?: 'expense' | 'income' | 'transfer'; + statuses?: TransactionStatus[]; + includeDeleted?: boolean; + }) { + const query: Record = {}; + 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('/finance/reimbursements', { + params: query, + }); + } + + /** + * 创建报销申请 + */ + export async function createReimbursement( + data: CreateReimbursementParams, + ) { + return requestClient.post('/finance/reimbursements', data); + } + + /** + * 更新报销申请 + */ + export async function updateReimbursement( + id: number, + data: Partial, + ) { + return requestClient.put(`/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(`/finance/reimbursements/${id}`, { + isDeleted: false, + }); + } + /** * 软删除交易 */ @@ -290,4 +429,30 @@ export namespace FinanceApi { isDeleted: false, }); } + + /** + * 获取媒体消息 + */ + export async function getMediaMessages(params?: { + limit?: number; + fileTypes?: string[]; + }) { + const query: Record = {}; + if (params?.limit) { + query.limit = params.limit; + } + if (params?.fileTypes && params.fileTypes.length > 0) { + query.types = params.fileTypes.join(','); + } + return requestClient.get('/finance/media', { + params: query, + }); + } + + /** + * 获取单条媒体消息详情 + */ + export async function getMediaMessage(id: number) { + return requestClient.get(`/finance/media/${id}`); + } } diff --git a/apps/web-antd/src/app.vue b/apps/web-antd/src/app.vue index eb8c5f09..b15b475a 100644 --- a/apps/web-antd/src/app.vue +++ b/apps/web-antd/src/app.vue @@ -49,8 +49,7 @@ const flattenFinWiseProMenu = () => { if (!childrenUL || !parentMenu) return; // Check if already processed - if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') - return; + if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return; // Move all children to the parent menu const children = [...childrenUL.children]; diff --git a/apps/web-antd/src/main.ts b/apps/web-antd/src/main.ts index b7e0613d..d5ddba18 100644 --- a/apps/web-antd/src/main.ts +++ b/apps/web-antd/src/main.ts @@ -52,8 +52,7 @@ function flattenFinWiseProMenu() { if (!childrenUL || !parentMenu) return; // Check if already processed - if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') - return; + if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return; // Move all children to the parent menu const children = [...childrenUL.children]; diff --git a/apps/web-antd/src/router/index.ts b/apps/web-antd/src/router/index.ts index cf8240ad..d7353bc9 100644 --- a/apps/web-antd/src/router/index.ts +++ b/apps/web-antd/src/router/index.ts @@ -55,10 +55,7 @@ router.afterEach(() => { if (!childrenUL || !parentMenu) return; // Check if already processed - if ( - (finwiseMenu as HTMLElement).dataset.hideFinwise === 'true' - ) - return; + if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return; // Move all children to the parent menu const children = [...childrenUL.children]; diff --git a/apps/web-antd/src/router/routes/modules/business-modules.ts b/apps/web-antd/src/router/routes/modules/business-modules.ts index a8651e57..9f139020 100644 --- a/apps/web-antd/src/router/routes/modules/business-modules.ts +++ b/apps/web-antd/src/router/routes/modules/business-modules.ts @@ -79,6 +79,57 @@ const routes: RouteRecordRaw[] = [ 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', path: '/tools', @@ -86,10 +137,21 @@ const routes: RouteRecordRaw[] = [ component: () => import('#/views/finance/tools/index.vue'), meta: { icon: 'mdi:tools', - order: 8, + order: 11, 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', path: '/fin-settings', @@ -97,7 +159,7 @@ const routes: RouteRecordRaw[] = [ component: () => import('#/views/finance/settings/index.vue'), meta: { icon: 'mdi:cog', - order: 9, + order: 13, title: '⚙️ 系统设置', }, }, diff --git a/apps/web-antd/src/store/finance.ts b/apps/web-antd/src/store/finance.ts index 8d2e3f7a..979a471b 100644 --- a/apps/web-antd/src/store/finance.ts +++ b/apps/web-antd/src/store/finance.ts @@ -13,6 +13,8 @@ export const useFinanceStore = defineStore('finance', () => { const exchangeRates = ref([]); const transactions = ref([]); const budgets = ref([]); + const reimbursements = ref([]); + const mediaMessages = ref([]); // 加载状态 const loading = ref({ @@ -22,6 +24,8 @@ export const useFinanceStore = defineStore('finance', () => { exchangeRates: false, transactions: 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; try { - transactions.value = await FinanceApi.getTransactions(); + transactions.value = await FinanceApi.getTransactions(params); } finally { 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) { const transaction = await FinanceApi.createTransaction(data); @@ -195,6 +216,80 @@ export const useFinanceStore = defineStore('finance', () => { 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, + ) { + 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) { return currencies.value.find((c) => c.code === code); @@ -296,6 +391,8 @@ export const useFinanceStore = defineStore('finance', () => { exchangeRates, transactions, budgets, + reimbursements, + mediaMessages, loading, // 方法 @@ -307,10 +404,16 @@ export const useFinanceStore = defineStore('finance', () => { fetchAccounts, fetchExchangeRates, fetchTransactions, + fetchMediaMessages, createTransaction, updateTransaction, softDeleteTransaction, restoreTransaction, + fetchReimbursements, + createReimbursement, + updateReimbursement, + deleteReimbursement, + restoreReimbursement, fetchBudgets, createBudget, updateBudget, diff --git a/apps/web-antd/src/views/finance/accounts/index.vue b/apps/web-antd/src/views/finance/accounts/index.vue index 01eb77fc..acf5e74a 100644 --- a/apps/web-antd/src/views/finance/accounts/index.vue +++ b/apps/web-antd/src/views/finance/accounts/index.vue @@ -268,11 +268,11 @@ const resetForm = () => { const getCurrencySymbol = (currency: string) => { const symbolMap: Record = { - CNY: '¥', + CNY: '$', THB: '฿', USD: '$', EUR: '€', - JPY: '¥', + JPY: '$', GBP: '£', HKD: 'HK$', KRW: '₩', diff --git a/apps/web-antd/src/views/finance/bills/index.vue b/apps/web-antd/src/views/finance/bills/index.vue index ebfcfc0b..0e502fa2 100644 --- a/apps/web-antd/src/views/finance/bills/index.vue +++ b/apps/web-antd/src/views/finance/bills/index.vue @@ -1,25 +1,59 @@ + + -
-
📱
-

暂无账单记录

-

添加您的常用账单,系统将自动提醒

+
+
📱
+

暂无账单记录

+

添加您的常用账单,系统将自动提醒

-
+
{{ bill.emoji }}

{{ bill.name }}

-

{{ bill.provider }} · 每{{ bill.cycle }}缴费

+

+ {{ bill.provider }} · 每{{ bill.cycle }}缴费 +

-

¥{{ bill.amount.toLocaleString() }}

+

${{ bill.amount.toLocaleString() }}

下次: {{ bill.nextDue }}

@@ -66,11 +106,13 @@ -
+
-
+
-
📈
+
📈

月度账单趋势

@@ -78,56 +120,37 @@
-
+
提前提醒天数 - +
-
+
短信提醒
-
+
邮件提醒
-
+
应用通知
- +
- - \ No newline at end of file +.grid { + display: grid; +} + diff --git a/apps/web-antd/src/views/finance/categories/index.vue b/apps/web-antd/src/views/finance/categories/index.vue index 57646393..7f8662a4 100644 --- a/apps/web-antd/src/views/finance/categories/index.vue +++ b/apps/web-antd/src/views/finance/categories/index.vue @@ -370,7 +370,7 @@ const setBudget = (category: any) => {

预算总额

- ¥{{ categoryStats.budgetTotal.toLocaleString() }} + ${{ categoryStats.budgetTotal.toLocaleString() }}

diff --git a/apps/web-antd/src/views/finance/dashboard/index.vue b/apps/web-antd/src/views/finance/dashboard/index.vue index c129f605..d77a245f 100644 --- a/apps/web-antd/src/views/finance/dashboard/index.vue +++ b/apps/web-antd/src/views/finance/dashboard/index.vue @@ -133,7 +133,7 @@ const baseCurrencySymbol = computed(() => { const baseCurrency = financeStore.currencies.find( (currency) => currency.isBase, ); - return baseCurrency?.symbol || '¥'; + return baseCurrency?.symbol || '$'; }); const formatCurrency = (value: number) => { @@ -203,9 +203,9 @@ const trendChartData = computed(() => { bucketKeys.forEach((key) => { const bucket = bucketMap.get(key) ?? { income: 0, expense: 0 }; const label = useMonthlyBucket - ? (english + ? english ? dayjs(key).format('MMM') - : dayjs(key).format('MM月')) + : dayjs(key).format('MM月') : dayjs(key).format('MM-DD'); labels.push(label); const income = Number(bucket.income.toFixed(2)); @@ -1039,7 +1039,9 @@ onMounted(async () => { :style="{ width: `${item.percentage}%` }" >
- {{ item.percentage }}% + {{ item.percentage }}%
{{ item.count }} {{ isEnglish ? 'records' : '笔交易' }} diff --git a/apps/web-antd/src/views/finance/expense-tracking/index.vue b/apps/web-antd/src/views/finance/expense-tracking/index.vue index 37095b67..8d5888ff 100644 --- a/apps/web-antd/src/views/finance/expense-tracking/index.vue +++ b/apps/web-antd/src/views/finance/expense-tracking/index.vue @@ -1,232 +1,20 @@ - - + + \ No newline at end of file +.grid { + display: grid; +} + diff --git a/apps/web-antd/src/views/finance/invoices/index.vue b/apps/web-antd/src/views/finance/invoices/index.vue index 4f91e8fe..203caaa0 100644 --- a/apps/web-antd/src/views/finance/invoices/index.vue +++ b/apps/web-antd/src/views/finance/invoices/index.vue @@ -1,38 +1,183 @@ + +