Compare commits
10 Commits
4b4616de1e
...
f4cd0a5f22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4cd0a5f22 | ||
|
|
2c0505b73d | ||
|
|
5a9a9c68b8 | ||
|
|
1def26f74f | ||
|
|
a1dc8de7e5 | ||
|
|
1e42191296 | ||
|
|
9683b940bf | ||
|
|
9be0b9788f | ||
|
|
6d82e8bf3d | ||
|
|
675fe0a1a8 |
@@ -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)
|
||||
@@ -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": []
|
||||
}
|
||||
14
.github/CODEOWNERS
vendored
14
.github/CODEOWNERS
vendored
@@ -1,14 +0,0 @@
|
||||
# default onwer
|
||||
* anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||
|
||||
# vben core onwer
|
||||
/.github/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||
/.vscode/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||
/packages/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||
/packages/@core/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||
/internal/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||
/scripts/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com jinmao88@qq.com
|
||||
|
||||
# vben team onwer
|
||||
apps/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com @vbenjs/team-v5 jinmao88@qq.com
|
||||
docs/ anncwb@126.com vince292007@gmail.com netfan@foxmail.com @vbenjs/team-v5 jinmao88@qq.com
|
||||
74
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
74
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,74 +0,0 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Report an issue with Vben Admin to help us make it better.
|
||||
title: 'Bug: '
|
||||
labels: ['bug: pending triage']
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of our software are you running?
|
||||
options:
|
||||
- Vben Admin V5
|
||||
- Vben Admin V2
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug-desc
|
||||
attributes:
|
||||
label: Describe the bug?
|
||||
description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks!
|
||||
placeholder: Bug Description
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction
|
||||
description: Please provide a link to [StackBlitz](https://stackblitz.com/fork/github/vitest-dev/vitest/tree/main/examples/basic?initialPath=__vitest__/) (you can also use [examples](https://github.com/vitest-dev/vitest/tree/main/examples)) or a github repo that can reproduce the problem you ran into. A [minimal reproduction](https://stackoverflow.com/help/minimal-reproducible-example) is required unless you are absolutely sure that the issue is obvious and the provided information is enough to understand the problem. If a report is vague (e.g. just a generic error message) and has no reproduction, it will receive a "needs reproduction" label. If no reproduction is provided after 3 days, it will be auto-closed.
|
||||
placeholder: Reproduction
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
label: System Info
|
||||
description: Output of `npx envinfo --system --npmPackages '{vue}' --binaries --browsers`
|
||||
render: shell
|
||||
placeholder: System, Binaries, Browsers
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Validations
|
||||
description: Before submitting the issue, please make sure you do the following
|
||||
# description: By submitting this issue, you agree to follow our [Code of Conduct](https://example.com).
|
||||
options:
|
||||
- label: Read the [docs](https://doc.vben.pro/)
|
||||
required: true
|
||||
- label: Ensure the code is up to date. (Some issues have been fixed in the latest version)
|
||||
required: true
|
||||
- label: I have searched the [existing issues](https://github.com/vbenjs/vue-vben-admin/issues) and checked that my issue does not duplicate any existing issues.
|
||||
required: true
|
||||
- label: Check that this is a concrete bug. For Q&A open a [GitHub Discussion](https://github.com/vbenjs/vue-vben-admin/discussions) or join our [Discord Chat Server](https://discord.gg/8GuAdwDhj6).
|
||||
required: true
|
||||
- label: The provided reproduction is a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) of the bug.
|
||||
required: true
|
||||
38
.github/ISSUE_TEMPLATE/docs.yml
vendored
38
.github/ISSUE_TEMPLATE/docs.yml
vendored
@@ -1,38 +0,0 @@
|
||||
name: 📚 Documentation
|
||||
description: Report an issue with Vben Admin Website to help us make it better.
|
||||
title: 'Docs: '
|
||||
labels: [documentation]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this issue!
|
||||
- type: checkboxes
|
||||
id: documentation_is
|
||||
attributes:
|
||||
label: Documentation is
|
||||
options:
|
||||
- label: Missing
|
||||
- label: Outdated
|
||||
- label: Confusing
|
||||
- label: Not sure?
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Explain in Detail
|
||||
description: A clear and concise description of your suggestion. If you intend to submit a PR for this issue, tell us in the description. Thanks!
|
||||
placeholder: The description of ... page is not clear. I thought it meant ... but it wasn't.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: suggestion
|
||||
attributes:
|
||||
label: Your Suggestion for Changes
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Please provide any reproduction steps that may need to be described. E.g. if it happens only when running the dev or build script make sure it's clear which one to use.
|
||||
placeholder: Run `pnpm install` followed by `pnpm run docs:dev`
|
||||
70
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
70
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: ✨ New Feature Proposal
|
||||
description: Propose a new feature to be added to Vben Admin
|
||||
title: 'FEATURE: '
|
||||
labels: ['enhancement: pending triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for suggesting a feature for our project! Please fill out the information below to help us understand and implement your request!
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of our software are you running?
|
||||
options:
|
||||
- Vben Admin V5
|
||||
- Vben Admin V2
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A detailed description of the feature request.
|
||||
placeholder: Please describe the feature you would like to see, and why it would be useful.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: A clear and concise description of what you want to happen.
|
||||
placeholder: Describe the solution you'd like to see
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: |
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
placeholder: Describe any alternative solutions or features you've considered
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
placeholder: Any additional information
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checkboxes
|
||||
attributes:
|
||||
label: Validations
|
||||
description: Before submitting the issue, please make sure you do the following
|
||||
options:
|
||||
- label: Read the [docs](https://doc.vben.pro/)
|
||||
required: true
|
||||
- label: Ensure the code is up to date. (Some issues have been fixed in the latest version)
|
||||
required: true
|
||||
- label: I have searched the [existing issues](https://github.com/vbenjs/vue-vben-admin/issues) and checked that my issue does not duplicate any existing issues.
|
||||
required: true
|
||||
40
.github/actions/setup-node/action.yml
vendored
40
.github/actions/setup-node/action.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: 'Setup Node'
|
||||
|
||||
description: 'Setup node and pnpm'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
if: ${{ github.ref_name == 'main' }}
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- uses: actions/cache/restore@v4
|
||||
if: ${{ github.ref_name != 'main' }}
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: pnpm install --frozen-lockfile
|
||||
89
.github/commit-convention.md
vendored
89
.github/commit-convention.md
vendored
@@ -1,89 +0,0 @@
|
||||
## Git Commit Message Convention
|
||||
|
||||
> This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular).
|
||||
|
||||
#### TL;DR:
|
||||
|
||||
Messages must be matched by the following regex:
|
||||
|
||||
```js
|
||||
/^(revert: )?(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|types|wip): .{1,50}/;
|
||||
```
|
||||
|
||||
#### Examples
|
||||
|
||||
Appears under "Features" header, `dev` subheader:
|
||||
|
||||
```
|
||||
feat(dev): add 'comments' option
|
||||
```
|
||||
|
||||
Appears under "Bug Fixes" header, `dev` subheader, with a link to issue #28:
|
||||
|
||||
```
|
||||
fix(dev): fix dev error
|
||||
|
||||
close #28
|
||||
```
|
||||
|
||||
Appears under "Performance Improvements" header, and under "Breaking Changes" with the breaking change explanation:
|
||||
|
||||
```
|
||||
perf(build): remove 'foo' option
|
||||
|
||||
BREAKING CHANGE: The 'foo' option has been removed.
|
||||
```
|
||||
|
||||
The following commit and commit `667ecc1` do not appear in the changelog if they are under the same release. If not, the revert commit appears under the "Reverts" header.
|
||||
|
||||
```
|
||||
revert: feat(compiler): add 'comments' option
|
||||
|
||||
This reverts commit 667ecc1654a317a13331b17617d973392f415f02.
|
||||
```
|
||||
|
||||
### Full Message Format
|
||||
|
||||
A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
<BLANK LINE>
|
||||
<body>
|
||||
<BLANK LINE>
|
||||
<footer>
|
||||
```
|
||||
|
||||
The **header** is mandatory and the **scope** of the header is optional.
|
||||
|
||||
### Revert
|
||||
|
||||
If the commit reverts a previous commit, it should begin with `revert: `, followed by the header of the reverted commit. In the body, it should say: `This reverts commit <hash>.`, where the hash is the SHA of the commit being reverted.
|
||||
|
||||
### Type
|
||||
|
||||
If the prefix is `feat`, `fix` or `perf`, it will appear in the changelog. However, if there is any [BREAKING CHANGE](#footer), the commit will always appear in the changelog.
|
||||
|
||||
Other prefixes are up to your discretion. Suggested prefixes are `docs`, `chore`, `style`, `refactor`, and `test` for non-changelog related tasks.
|
||||
|
||||
### Scope
|
||||
|
||||
The scope could be anything specifying the place of the commit change. For example `dev`, `build`, `workflow`, `cli` etc...
|
||||
|
||||
### Subject
|
||||
|
||||
The subject contains a succinct description of the change:
|
||||
|
||||
- use the imperative, present tense: "change" not "changed" nor "changes"
|
||||
- don't capitalize the first letter
|
||||
- no dot (.) at the end
|
||||
|
||||
### Body
|
||||
|
||||
Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". The body should include the motivation for the change and contrast this with previous behavior.
|
||||
|
||||
### Footer
|
||||
|
||||
The footer should contain any information about **Breaking Changes** and is also the place to reference GitHub issues that this commit **Closes**.
|
||||
|
||||
**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this.
|
||||
39
.github/config.yml
vendored
39
.github/config.yml
vendored
@@ -1,39 +0,0 @@
|
||||
# Prevent issues being created without using the template
|
||||
blank_issues_enabled: false
|
||||
checkIssueTemplate: true
|
||||
checkPullRequestTemplate: true
|
||||
|
||||
contact_links:
|
||||
- name: 💬 Discord Chat
|
||||
url: https://discord.gg/8GuAdwDhj6
|
||||
about: Ask questions and discuss with other Vben users in real time.
|
||||
|
||||
- name: ❓ Questions & Discussions
|
||||
url: https://github.com/@vbenjs/vue-vben-admin/discussions
|
||||
about: Use GitHub discussions for message-board style questions and discussions.
|
||||
|
||||
# Comment to be posted to on PRs from first time contributors in your repository
|
||||
newPRWelcomeComment: |
|
||||
💖 Thanks for opening this pull request! 💖
|
||||
Please be patient and we will get back to you as soon as we can.
|
||||
|
||||
# Comment to be posted to on pull requests merged by a first time user
|
||||
firstPRMergeComment: >
|
||||
Thanks for your contribution! 🎉🎉🎉
|
||||
|
||||
|
||||
# Comment to be posted to on first time issues
|
||||
newIssueWelcomeComment: >
|
||||
Thanks for opening your first issue! Be sure to follow the issue template and provide every bit of information to help the developers!
|
||||
|
||||
|
||||
# *OPTIONAL* default titles to check against for lack of descriptiveness
|
||||
# MUST BE ALL LOWERCASE
|
||||
requestInfoDefaultTitles:
|
||||
- update readme.md
|
||||
- updates
|
||||
|
||||
# *Required* Comment to reply with
|
||||
requestInfoReplyComment: >
|
||||
Thanks for filing this issue/PR! It would be much appreciated if you could provide us with more information so we can effectively analyze the situation in context.
|
||||
|
||||
40
.github/contributing.md
vendored
40
.github/contributing.md
vendored
@@ -1,40 +0,0 @@
|
||||
# Vben Admin Contributing Guide
|
||||
|
||||
Hi! We're really excited that you are interested in contributing to Vben Admin. Before submitting your contribution, please make sure to take a moment and read through the following guidelines:
|
||||
|
||||
- [Pull Request Guidelines](#pull-request-guidelines)
|
||||
|
||||
## Contributor Code of Conduct
|
||||
|
||||
As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
|
||||
|
||||
We are committed to making participation in this project a harassment-free experience for everyone, regardless of the level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion.
|
||||
|
||||
Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- Checkout a topic branch from the relevant branch, e.g. main, and merge back against that branch.
|
||||
|
||||
- If adding a new feature:
|
||||
- Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first and have it approved before working on it.
|
||||
|
||||
- If fixing bug:
|
||||
- Provide a detailed description of the bug in the PR. Live demo preferred.
|
||||
|
||||
- It's OK to have multiple small commits as you work on the PR - GitHub can automatically squash them before merging.
|
||||
|
||||
## Development Setup
|
||||
|
||||
You will need [pnpm](https://pnpm.io/)
|
||||
|
||||
After cloning the repo, run:
|
||||
|
||||
```bash
|
||||
# install the dependencies of the project
|
||||
$ pnpm install
|
||||
# start the project
|
||||
$ pnpm run dev
|
||||
```
|
||||
17
.github/dependabot.yml
vendored
17
.github/dependabot.yml
vendored
@@ -1,17 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: daily
|
||||
groups:
|
||||
non-breaking-changes:
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
non-breaking-changes:
|
||||
update-types: [minor, patch]
|
||||
33
.github/pull_request_template.md
vendored
33
.github/pull_request_template.md
vendored
@@ -1,33 +0,0 @@
|
||||
## Description
|
||||
|
||||
<!-- Please describe the change as necessary. If it's a feature or enhancement please be as detailed as possible. If it's a bug fix, please link the issue that it fixes or describe the bug in as much detail.
|
||||
|
||||
-->
|
||||
|
||||
<!-- You can also add additional context here -->
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
- [ ] Please, don't make changes to `pnpm-lock.yaml` unless you introduce a new test example.
|
||||
|
||||
## Checklist
|
||||
|
||||
> ℹ️ Check all checkboxes - this will indicate that you have done everything in accordance with the rules in [CONTRIBUTING](contributing.md).
|
||||
|
||||
- [ ] If you introduce new functionality, document it. You can run documentation with `pnpm run docs:dev` command.
|
||||
- [ ] Run the tests with `pnpm test`.
|
||||
- [ ] Changes in changelog are generated from PR name. Please, make sure that it explains your changes in an understandable manner. Please, prefix changeset messages with `feat:`, `fix:`, `perf:`, `docs:`, or `chore:`.
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published in downstream modules
|
||||
61
.github/release-drafter.yml
vendored
61
.github/release-drafter.yml
vendored
@@ -1,61 +0,0 @@
|
||||
name-template: 'v$RESOLVED_VERSION'
|
||||
tag-template: 'v$RESOLVED_VERSION'
|
||||
version-template: $MAJOR.$MINOR.$PATCH
|
||||
change-template: '* $TITLE (#$NUMBER) @$AUTHOR'
|
||||
template: |
|
||||
# What's Changed
|
||||
|
||||
$CHANGES
|
||||
|
||||
**Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION
|
||||
|
||||
categories:
|
||||
- title: '🚀 Features'
|
||||
labels:
|
||||
- 'feature'
|
||||
- title: '🐞 Bug Fixes'
|
||||
labels:
|
||||
- 'bug'
|
||||
- title: '📈 Performance & Enhancement'
|
||||
labels:
|
||||
- 'perf'
|
||||
- 'enhancement'
|
||||
- title: 📝 Documentation
|
||||
labels:
|
||||
- 'documentation'
|
||||
- title: 👻 Maintenance
|
||||
labels:
|
||||
- 'chore'
|
||||
- 'dependencies'
|
||||
# collapse-after: 12
|
||||
- title: 🚦 Tests
|
||||
labels:
|
||||
- 'tests'
|
||||
- title: 'Breaking'
|
||||
label: 'breaking'
|
||||
|
||||
version-resolver:
|
||||
major:
|
||||
labels:
|
||||
- 'major'
|
||||
- 'breaking'
|
||||
minor:
|
||||
labels:
|
||||
- 'minor'
|
||||
patch:
|
||||
labels:
|
||||
- 'feature'
|
||||
- 'patch'
|
||||
- 'bug'
|
||||
- 'maintenance'
|
||||
- 'docs'
|
||||
- 'dependencies'
|
||||
- 'security'
|
||||
|
||||
exclude-labels:
|
||||
- 'skip-changelog'
|
||||
- 'no-changelog'
|
||||
- 'changelog'
|
||||
- 'bump versions'
|
||||
- 'reverted'
|
||||
- 'invalid'
|
||||
13
.github/semantic.yml
vendored
13
.github/semantic.yml
vendored
@@ -1,13 +0,0 @@
|
||||
titleAndCommits: true
|
||||
types:
|
||||
- feat
|
||||
- fix
|
||||
- docs
|
||||
- chore
|
||||
- style
|
||||
- refactor
|
||||
- perf
|
||||
- test
|
||||
- build
|
||||
- ci
|
||||
- revert
|
||||
48
.github/workflows/build.yml
vendored
48
.github/workflows/build.yml
vendored
@@ -1,48 +0,0 @@
|
||||
# name: Dependabot post-update
|
||||
name: Build detection
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
HUSKY: '0'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
post-update:
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
# if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
# - macos-latest
|
||||
- windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout out pull request
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr checkout ${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
pnpm run build
|
||||
42
.github/workflows/changeset-version.yml
vendored
42
.github/workflows/changeset-version.yml
vendored
@@ -1,42 +0,0 @@
|
||||
# https://github.com/changesets/action
|
||||
name: Changeset version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
jobs:
|
||||
version:
|
||||
if: (github.event.pull_request.merged || github.event_name == 'workflow_dispatch') && github.actor != 'dependabot[bot]' && !contains(github.event.head_commit.message, '[skip ci]') && github.repository == 'vbenjs/vue-vben-admin'
|
||||
# if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Create Release Pull Request
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm run version
|
||||
commit: 'chore: bump versions'
|
||||
title: 'chore: bump versions'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
125
.github/workflows/ci.yml
vendored
125
.github/workflows/ci.yml
vendored
@@ -1,125 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'releases/*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CI: true
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
# - macos-latest
|
||||
- windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
# - name: Check Git version
|
||||
# run: git --version
|
||||
|
||||
# - name: Setup mock Git user
|
||||
# run: git config --global user.email "you@example.com" && git config --global user.name "Your Name"
|
||||
|
||||
- name: Vitest tests
|
||||
run: pnpm run test:unit
|
||||
|
||||
# - name: Upload coverage
|
||||
# uses: codecov/codecov-action@v4
|
||||
# with:
|
||||
# token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
# - macos-latest
|
||||
- windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
check:
|
||||
name: Check
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
# - macos-latest
|
||||
- windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm check:type
|
||||
|
||||
# From https://github.com/rhysd/actionlint/blob/main/docs/usage.md#use-actionlint-on-github-actions
|
||||
- name: Check workflow files
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
|
||||
./actionlint -color -shellcheck=""
|
||||
|
||||
ci-ok:
|
||||
name: CI OK
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, check, lint]
|
||||
env:
|
||||
FAILURE: ${{ contains(join(needs.*.result, ','), 'failure') }}
|
||||
steps:
|
||||
- name: Check for failure
|
||||
run: |
|
||||
echo $FAILURE
|
||||
if [ "$FAILURE" = "false" ]; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
94
.github/workflows/codeql.yml
vendored
94
.github/workflows/codeql.yml
vendored
@@ -1,94 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: 'CodeQL'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
schedule:
|
||||
- cron: '35 0 * * 0'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
172
.github/workflows/deploy.yml
vendored
172
.github/workflows/deploy.yml
vendored
@@ -1,172 +0,0 @@
|
||||
name: Deploy Website on push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy-playground-ftp:
|
||||
name: Deploy Push Playground Ftp
|
||||
if: github.actor != 'dependabot[bot]' && !contains(github.event.head_commit.message, '[skip ci]') && github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sed Config Base
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i "s#VITE_COMPRESS\s*=.*#VITE_COMPRESS = gzip#g" ./playground/.env.production
|
||||
sed -i "s#VITE_PWA\s*=.*#VITE_PWA = true#g" ./playground/.env.production
|
||||
cat ./playground/.env.production
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Build
|
||||
run: pnpm build:play
|
||||
|
||||
- name: Sync Playground files
|
||||
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
|
||||
with:
|
||||
server: ${{ secrets.PRO_FTP_HOST }}
|
||||
username: ${{ secrets.WEB_PLAYGROUND_FTP_ACCOUNT }}
|
||||
password: ${{ secrets.WEB_PLAYGROUND_FTP_PWSSWORD }}
|
||||
local-dir: ./playground/dist/
|
||||
|
||||
deploy-docs-ftp:
|
||||
name: Deploy Push Docs Ftp
|
||||
if: github.actor != 'dependabot[bot]' && !contains(github.event.head_commit.message, '[skip ci]') && github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Build
|
||||
run: pnpm build:docs
|
||||
|
||||
- name: Sync Docs files
|
||||
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
|
||||
with:
|
||||
server: ${{ secrets.PRO_FTP_HOST }}
|
||||
username: ${{ secrets.WEBSITE_FTP_ACCOUNT }}
|
||||
password: ${{ secrets.WEBSITE_FTP_PASSWORD }}
|
||||
local-dir: ./docs/.vitepress/dist/
|
||||
|
||||
deploy-antd-ftp:
|
||||
name: Deploy Push Antd Ftp
|
||||
if: github.actor != 'dependabot[bot]' && !contains(github.event.head_commit.message, '[skip ci]') && github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sed Config Base
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i "s#VITE_COMPRESS\s*=.*#VITE_COMPRESS = gzip#g" ./apps/web-antd/.env.production
|
||||
sed -i "s#VITE_PWA\s*=.*#VITE_PWA = true#g" ./apps/web-antd/.env.production
|
||||
cat ./apps/web-antd/.env.production
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build:antd
|
||||
|
||||
- name: Sync files
|
||||
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
|
||||
with:
|
||||
server: ${{ secrets.PRO_FTP_HOST }}
|
||||
username: ${{ secrets.WEB_ANTD_FTP_ACCOUNT }}
|
||||
password: ${{ secrets.WEB_ANTD_FTP_PASSWORD }}
|
||||
local-dir: ./apps/web-antd/dist/
|
||||
|
||||
deploy-ele-ftp:
|
||||
name: Deploy Push Element Ftp
|
||||
if: github.actor != 'dependabot[bot]' && !contains(github.event.head_commit.message, '[skip ci]') && github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sed Config Base
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i "s#VITE_COMPRESS\s*=.*#VITE_COMPRESS = gzip#g" ./apps/web-ele/.env.production
|
||||
sed -i "s#VITE_PWA\s*=.*#VITE_PWA = true#g" ./apps/web-ele/.env.production
|
||||
cat ./apps/web-ele/.env.production
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build:ele
|
||||
|
||||
- name: Sync files
|
||||
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
|
||||
with:
|
||||
server: ${{ secrets.PRO_FTP_HOST }}
|
||||
username: ${{ secrets.WEB_ELE_FTP_ACCOUNT }}
|
||||
password: ${{ secrets.WEB_ELE_FTP_PASSWORD }}
|
||||
local-dir: ./apps/web-ele/dist/
|
||||
|
||||
deploy-naive-ftp:
|
||||
name: Deploy Push Naive Ftp
|
||||
if: github.actor != 'dependabot[bot]' && !contains(github.event.head_commit.message, '[skip ci]') && github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sed Config Base
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i "s#VITE_COMPRESS\s*=.*#VITE_COMPRESS = gzip#g" ./apps/web-naive/.env.production
|
||||
sed -i "s#VITE_PWA\s*=.*#VITE_PWA = true#g" ./apps/web-naive/.env.production
|
||||
cat ./apps/web-naive/.env.production
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build:naive
|
||||
|
||||
- name: Sync files
|
||||
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
|
||||
with:
|
||||
server: ${{ secrets.PRO_FTP_HOST }}
|
||||
username: ${{ secrets.WEB_NAIVE_FTP_ACCOUNT }}
|
||||
password: ${{ secrets.WEB_NAIVE_FTP_PASSWORD }}
|
||||
local-dir: ./apps/web-naive/dist/
|
||||
|
||||
rerun-on-failure:
|
||||
name: Rerun on failure
|
||||
needs:
|
||||
- deploy-playground-ftp
|
||||
- deploy-docs-ftp
|
||||
- deploy-antd-ftp
|
||||
- deploy-ele-ftp
|
||||
- deploy-naive-ftp
|
||||
if: failure() && fromJSON(github.run_attempt) < 10
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Retry ${{ fromJSON(github.run_attempt) }} of 10
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run rerun.yml -F run_id=${{ github.run_id }}
|
||||
25
.github/workflows/draft.yml
vendored
25
.github/workflows/draft.yml
vendored
@@ -1,25 +0,0 @@
|
||||
name: Release Drafter
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update_release_draft:
|
||||
permissions:
|
||||
# write permission is required to create a github release
|
||||
contents: write
|
||||
# write permission is required for autolabeler
|
||||
# otherwise, read permission is required at least
|
||||
pull-requests: write
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
31
.github/workflows/issue-close-require.yml
vendored
31
.github/workflows/issue-close-require.yml
vendored
@@ -1,31 +0,0 @@
|
||||
# 每天零点运行一次,它会检查所有带有 "need reproduction" 标签的 Issues。如果这些 Issues 在过去的 3 天内没有任何活动,它们将会被自动关闭。这有助于保持 Issue 列表的整洁,并且提醒用户在必要时提供更多的信息。
|
||||
name: Issue Close Require
|
||||
|
||||
# 触发条件:每天零点
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# 关闭未活动的 Issues
|
||||
- name: Close Inactive Issues
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
days-before-stale: -1 # Issues and PR will never be flagged stale automatically.
|
||||
stale-issue-label: needs-reproduction # Label that flags an issue as stale.
|
||||
only-labels: needs-reproduction # Only process these issues
|
||||
days-before-issue-close: 3
|
||||
ignore-updates: true
|
||||
remove-stale-when-updated: false
|
||||
close-issue-message: This issue was closed because it was open for 3 days without a valid reproduction.
|
||||
close-issue-label: closed-by-action
|
||||
46
.github/workflows/issue-labeled.yml
vendored
46
.github/workflows/issue-labeled.yml
vendored
@@ -1,46 +0,0 @@
|
||||
name: Label Based Actions
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
# pull_request:
|
||||
# types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
reply-labeled:
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: remove enhancement pending
|
||||
if: github.event.label.name == 'enhancement'
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'remove-labels'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
labels: 'enhancement: pending triage'
|
||||
|
||||
- name: remove bug pending
|
||||
if: github.event.label.name == 'bug'
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'remove-labels'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
labels: 'bug: pending triage'
|
||||
|
||||
- name: needs reproduction
|
||||
if: github.event.label.name == 'needs reproduction'
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'create-comment, remove-labels'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
Hello @${{ github.event.issue.user.login }}. Please provide the complete reproduction steps and code. Issues labeled by `needs reproduction` will be closed if no activities in 3 days.
|
||||
labels: 'bug: pending triage'
|
||||
24
.github/workflows/lock.yml
vendored
24
.github/workflows/lock.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: Lock Threads
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
action:
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-inactive-days: '14'
|
||||
issue-lock-reason: ''
|
||||
pr-inactive-days: '30'
|
||||
pr-lock-reason: ''
|
||||
process-only: 'issues, prs'
|
||||
80
.github/workflows/release-tag.yml
vendored
80
.github/workflows/release-tag.yml
vendored
@@ -1,80 +0,0 @@
|
||||
name: Create Release Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*' # Push events to matching v*, i.e. v1.0, v20.15.10
|
||||
|
||||
env:
|
||||
HUSKY: '0'
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Create Release
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v4
|
||||
# with:
|
||||
# fetch-depth: 0
|
||||
|
||||
# - name: Install pnpm
|
||||
# uses: pnpm/action-setup@v4
|
||||
|
||||
# - name: Use Node.js ${{ matrix.node-version }}
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: ${{ matrix.node-version }}
|
||||
# cache: "pnpm"
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: pnpm install --frozen-lockfile
|
||||
|
||||
# - name: Test and Build
|
||||
# run: |
|
||||
# pnpm run test
|
||||
# pnpm run build
|
||||
|
||||
- name: version
|
||||
id: version
|
||||
run: |
|
||||
tag=${GITHUB_REF/refs\/tags\//}
|
||||
version=${tag#v}
|
||||
major=${version%%.*}
|
||||
echo "tag=${tag}" >> $GITHUB_OUTPUT
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
echo "major=${major}" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: release-drafter/release-drafter@v6
|
||||
with:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
publish: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# - name: force update major tag
|
||||
# run: |
|
||||
# git tag v${{ steps.version.outputs.major }} ${{ steps.version.outputs.tag }} -f
|
||||
# git push origin refs/tags/v${{ steps.version.outputs.major }} -f
|
||||
|
||||
# - name: Create Release for Tag
|
||||
# id: release_tag
|
||||
# uses: ncipollo/release-action@v1
|
||||
# with:
|
||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# generateReleaseNotes: "true"
|
||||
# body: |
|
||||
# > Please refer to [CHANGELOG.md](https://github.com/vbenjs/vue-vben-admin/blob/main/CHANGELOG.md) for details.
|
||||
19
.github/workflows/rerun.yml
vendored
19
.github/workflows/rerun.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: Rerun workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_id:
|
||||
description: The workflow id to relanch
|
||||
required: true
|
||||
jobs:
|
||||
rerun:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: rerun ${{ inputs.run_id }}
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh run watch ${{ inputs.run_id }} > /dev/null 2>&1
|
||||
gh run rerun ${{ inputs.run_id }} --failed
|
||||
41
.github/workflows/semantic-pull-request.yml
vendored
41
.github/workflows/semantic-pull-request.yml
vendored
@@ -1,41 +0,0 @@
|
||||
name: Semantic Pull Request
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Semantic Pull Request
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: amannn/action-semantic-pull-request@v5
|
||||
with:
|
||||
wip: true
|
||||
subjectPattern: ^(?![A-Z]).+$
|
||||
subjectPatternError: |
|
||||
The subject "{subject}" found in the pull request title "{title}"
|
||||
didn't match the configured pattern. Please ensure that the subject
|
||||
doesn't start with an uppercase character.
|
||||
requireScope: false
|
||||
types: |
|
||||
fix
|
||||
feat
|
||||
docs
|
||||
style
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
build
|
||||
ci
|
||||
chore
|
||||
revert
|
||||
types
|
||||
release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
19
.github/workflows/stale.yml
vendored
19
.github/workflows/stale.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: 'Close stale issues'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 1 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'vbenjs/vue-vben-admin'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'
|
||||
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days'
|
||||
exempt-issue-labels: 'bug,enhancement'
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,6 +15,8 @@ coverage
|
||||
**/.vitepress/cache
|
||||
.cache
|
||||
.turbo
|
||||
.vercel
|
||||
storage/
|
||||
.temp
|
||||
dev-dist
|
||||
.stylelintcache
|
||||
|
||||
9
LICENSE
9
LICENSE
@@ -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.
|
||||
153
README.ja-JP.md
153
README.ja-JP.md
@@ -1,153 +0,0 @@
|
||||
<div align="center">
|
||||
<a href="https://github.com/anncwb/vue-vben-admin">
|
||||
<img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp">
|
||||
</a>
|
||||
<br>
|
||||
<br>
|
||||
|
||||
[](LICENSE)
|
||||
|
||||
<h1>Vue Vben Admin</h1>
|
||||
</div>
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=vbenjs_vue-vben-admin)    
|
||||
|
||||
**日本語** | [English](./README.md) | [中文](./README.zh-CN.md)
|
||||
|
||||
## 紹介
|
||||
|
||||
Vue Vben Adminは、最新の`vue3`、`vite`、`TypeScript`などの主流技術を使用して開発された、無料でオープンソースの中・後端テンプレートです。すぐに使える中・後端のフロントエンドソリューションとして、学習の参考にもなります。
|
||||
|
||||
## アップグレード通知
|
||||
|
||||
これは最新バージョン `5.0` であり、以前のバージョンとは互換性がありません。新しいプロジェクトを開始する場合は、最新バージョンを使用することをお勧めします。古いバージョンを表示したい場合は、[v2ブランチ](https://github.com/vbenjs/vue-vben-admin/tree/v2)を使用してください。
|
||||
|
||||
## 特徴
|
||||
|
||||
- **最新技術スタック**:Vue 3やViteなどの最先端フロントエンド技術で開発
|
||||
- **TypeScript**:アプリケーション規模のJavaScriptのための言語
|
||||
- **テーマ**:複数のテーマカラーが利用可能で、カスタマイズオプションも豊富
|
||||
- **国際化**:完全な内蔵国際化サポート
|
||||
- **権限管理**:動的ルートベースの権限生成ソリューションを内蔵
|
||||
|
||||
## プレビュー
|
||||
|
||||
- [Vben Admin](https://vben.pro/) - フルバージョンの中国語サイト
|
||||
|
||||
テストアカウント:vben/123456
|
||||
|
||||
<div align="center">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview1.png">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview2.png">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview3.png">
|
||||
</div>
|
||||
|
||||
### Gitpodを使用
|
||||
|
||||
Gitpod(GitHub用の無料オンライン開発環境)でプロジェクトを開き、すぐにコーディングを開始します。
|
||||
|
||||
[](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)
|
||||
|
||||
## 貢献方法
|
||||
|
||||
ご参加をお待ちしておりますするか、Pull Requestを送信してください。
|
||||
|
||||
**Pull Request プロセス:**
|
||||
|
||||
1. コードをフォーク
|
||||
2. 自分のブランチを作成:`git checkout -b feat/xxxx`
|
||||
3. 変更をコミット:`git commit -am 'feat(function): add xxxxx'`
|
||||
4. ブランチをプッシュ:`git push origin feat/xxxx`
|
||||
5. `pull request`を送信
|
||||
|
||||
## Git貢献提出規則
|
||||
|
||||
参考 [vue](https://github.com/vuejs/vue/blob/dev/.github/COMMIT_CONVENTION.md) 規則 ([Angular](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular))
|
||||
|
||||
- `feat` 新機能の追加
|
||||
- `fix` 問題/バグの修正
|
||||
- `style` コードスタイルに関連し、実行結果に影響しない
|
||||
- `perf` 最適化/パフォーマンス向上
|
||||
- `refactor` リファクタリング
|
||||
- `revert` 変更の取り消し
|
||||
- `test` テスト関連
|
||||
- `docs` ドキュメント/注釈
|
||||
- `chore` 依存関係の更新/スキャフォールディング設定の変更など
|
||||
- `ci` 継続的インテグレーション
|
||||
- `types` 型定義ファイルの変更
|
||||
|
||||
## ブラウザサポート
|
||||
|
||||
ローカル開発には `Chrome 80+` ブラウザを推奨します
|
||||
|
||||
モダンブラウザをサポートし、IEはサポートしません
|
||||
|
||||
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari |
|
||||
| :-: | :-: | :-: | :-: |
|
||||
| 最新2バージョン | 最新2バージョン | 最新2バージョン | 最新2バージョン |
|
||||
|
||||
## メンテナー
|
||||
|
||||
[@Vben](https://github.com/anncwb)
|
||||
|
||||
## スター歴史
|
||||
|
||||
[](https://star-history.com/#vbenjs/vue-vben-admin&Date)
|
||||
|
||||
## 寄付
|
||||
|
||||
このプロジェクトが役に立つと思われた場合、作者にコーヒーを一杯おごってサポートを示すことができます!
|
||||
|
||||

|
||||
|
||||
<a style="display: block;width: 100px;height: 50px;line-height: 50px; color: #fff;text-align: center; background: #408aed;border-radius: 4px;" href="https://www.paypal.com/paypalme/cvvben">Paypal Me</a>
|
||||
|
||||
## 貢献者
|
||||
|
||||
<a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors">
|
||||
<img alt="Contributors" src="https://opencollective.com/vbenjs/contributors.svg?button=false" />
|
||||
</a>
|
||||
|
||||
## Discord
|
||||
|
||||
- [Github Discussions](https://github.com/anncwb/vue-vben-admin/discussions)
|
||||
|
||||
## ライセンス
|
||||
|
||||
[MIT © Vben-2020](./LICENSE)
|
||||
BIN
analytics-success.png
Normal file
BIN
analytics-success.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -1,390 +0,0 @@
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
password: string;
|
||||
realName: string;
|
||||
roles: string[];
|
||||
username: string;
|
||||
homePath?: string;
|
||||
}
|
||||
|
||||
export const MOCK_USERS: UserInfo[] = [
|
||||
{
|
||||
id: 0,
|
||||
password: '123456',
|
||||
realName: 'Vben',
|
||||
roles: ['super'],
|
||||
username: 'vben',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
password: '123456',
|
||||
realName: 'Admin',
|
||||
roles: ['admin'],
|
||||
username: 'admin',
|
||||
homePath: '/workspace',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
password: '123456',
|
||||
realName: 'Jack',
|
||||
roles: ['user'],
|
||||
username: 'jack',
|
||||
homePath: '/analytics',
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_CODES = [
|
||||
// super
|
||||
{
|
||||
codes: ['AC_100100', 'AC_100110', 'AC_100120', 'AC_100010'],
|
||||
username: 'vben',
|
||||
},
|
||||
{
|
||||
// admin
|
||||
codes: ['AC_100010', 'AC_100020', 'AC_100030'],
|
||||
username: 'admin',
|
||||
},
|
||||
{
|
||||
// user
|
||||
codes: ['AC_1000001', 'AC_1000002'],
|
||||
username: 'jack',
|
||||
},
|
||||
];
|
||||
|
||||
const dashboardMenus = [
|
||||
{
|
||||
meta: {
|
||||
order: -1,
|
||||
title: 'page.dashboard.title',
|
||||
},
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
redirect: '/analytics',
|
||||
children: [
|
||||
{
|
||||
name: 'Analytics',
|
||||
path: '/analytics',
|
||||
component: '/dashboard/analytics/index',
|
||||
meta: {
|
||||
affixTab: true,
|
||||
title: 'page.dashboard.analytics',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Workspace',
|
||||
path: '/workspace',
|
||||
component: '/dashboard/workspace/index',
|
||||
meta: {
|
||||
title: 'page.dashboard.workspace',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const createDemosMenus = (role: 'admin' | 'super' | 'user') => {
|
||||
const roleWithMenus = {
|
||||
admin: {
|
||||
component: '/demos/access/admin-visible',
|
||||
meta: {
|
||||
icon: 'mdi:button-cursor',
|
||||
title: 'demos.access.adminVisible',
|
||||
},
|
||||
name: 'AccessAdminVisibleDemo',
|
||||
path: '/demos/access/admin-visible',
|
||||
},
|
||||
super: {
|
||||
component: '/demos/access/super-visible',
|
||||
meta: {
|
||||
icon: 'mdi:button-cursor',
|
||||
title: 'demos.access.superVisible',
|
||||
},
|
||||
name: 'AccessSuperVisibleDemo',
|
||||
path: '/demos/access/super-visible',
|
||||
},
|
||||
user: {
|
||||
component: '/demos/access/user-visible',
|
||||
meta: {
|
||||
icon: 'mdi:button-cursor',
|
||||
title: 'demos.access.userVisible',
|
||||
},
|
||||
name: 'AccessUserVisibleDemo',
|
||||
path: '/demos/access/user-visible',
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
meta: {
|
||||
icon: 'ic:baseline-view-in-ar',
|
||||
keepAlive: true,
|
||||
order: 1000,
|
||||
title: 'demos.title',
|
||||
},
|
||||
name: 'Demos',
|
||||
path: '/demos',
|
||||
redirect: '/demos/access',
|
||||
children: [
|
||||
{
|
||||
name: 'AccessDemos',
|
||||
path: '/demosaccess',
|
||||
meta: {
|
||||
icon: 'mdi:cloud-key-outline',
|
||||
title: 'demos.access.backendPermissions',
|
||||
},
|
||||
redirect: '/demos/access/page-control',
|
||||
children: [
|
||||
{
|
||||
name: 'AccessPageControlDemo',
|
||||
path: '/demos/access/page-control',
|
||||
component: '/demos/access/index',
|
||||
meta: {
|
||||
icon: 'mdi:page-previous-outline',
|
||||
title: 'demos.access.pageAccess',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AccessButtonControlDemo',
|
||||
path: '/demos/access/button-control',
|
||||
component: '/demos/access/button-control',
|
||||
meta: {
|
||||
icon: 'mdi:button-cursor',
|
||||
title: 'demos.access.buttonControl',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AccessMenuVisible403Demo',
|
||||
path: '/demos/access/menu-visible-403',
|
||||
component: '/demos/access/menu-visible-403',
|
||||
meta: {
|
||||
authority: ['no-body'],
|
||||
icon: 'mdi:button-cursor',
|
||||
menuVisibleWithForbidden: true,
|
||||
title: 'demos.access.menuVisible403',
|
||||
},
|
||||
},
|
||||
roleWithMenus[role],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const MOCK_MENUS = [
|
||||
{
|
||||
menus: [...dashboardMenus, ...createDemosMenus('super')],
|
||||
username: 'vben',
|
||||
},
|
||||
{
|
||||
menus: [...dashboardMenus, ...createDemosMenus('admin')],
|
||||
username: 'admin',
|
||||
},
|
||||
{
|
||||
menus: [...dashboardMenus, ...createDemosMenus('user')],
|
||||
username: 'jack',
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_MENU_LIST = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Workspace',
|
||||
status: 1,
|
||||
type: 'menu',
|
||||
icon: 'mdi:dashboard',
|
||||
path: '/workspace',
|
||||
component: '/dashboard/workspace/index',
|
||||
meta: {
|
||||
icon: 'carbon:workspace',
|
||||
title: 'page.dashboard.workspace',
|
||||
affixTab: true,
|
||||
order: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
meta: {
|
||||
icon: 'carbon:settings',
|
||||
order: 9997,
|
||||
title: 'system.title',
|
||||
badge: 'new',
|
||||
badgeType: 'normal',
|
||||
badgeVariants: 'primary',
|
||||
},
|
||||
status: 1,
|
||||
type: 'catalog',
|
||||
name: 'System',
|
||||
path: '/system',
|
||||
children: [
|
||||
{
|
||||
id: 201,
|
||||
pid: 2,
|
||||
path: '/system/menu',
|
||||
name: 'SystemMenu',
|
||||
authCode: 'System:Menu:List',
|
||||
status: 1,
|
||||
type: 'menu',
|
||||
meta: {
|
||||
icon: 'carbon:menu',
|
||||
title: 'system.menu.title',
|
||||
},
|
||||
component: '/system/menu/list',
|
||||
children: [
|
||||
{
|
||||
id: 20_101,
|
||||
pid: 201,
|
||||
name: 'SystemMenuCreate',
|
||||
status: 1,
|
||||
type: 'button',
|
||||
authCode: 'System:Menu:Create',
|
||||
meta: { title: 'common.create' },
|
||||
},
|
||||
{
|
||||
id: 20_102,
|
||||
pid: 201,
|
||||
name: 'SystemMenuEdit',
|
||||
status: 1,
|
||||
type: 'button',
|
||||
authCode: 'System:Menu:Edit',
|
||||
meta: { title: 'common.edit' },
|
||||
},
|
||||
{
|
||||
id: 20_103,
|
||||
pid: 201,
|
||||
name: 'SystemMenuDelete',
|
||||
status: 1,
|
||||
type: 'button',
|
||||
authCode: 'System:Menu:Delete',
|
||||
meta: { title: 'common.delete' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 202,
|
||||
pid: 2,
|
||||
path: '/system/dept',
|
||||
name: 'SystemDept',
|
||||
status: 1,
|
||||
type: 'menu',
|
||||
authCode: 'System:Dept:List',
|
||||
meta: {
|
||||
icon: 'carbon:container-services',
|
||||
title: 'system.dept.title',
|
||||
},
|
||||
component: '/system/dept/list',
|
||||
children: [
|
||||
{
|
||||
id: 20_401,
|
||||
pid: 201,
|
||||
name: 'SystemDeptCreate',
|
||||
status: 1,
|
||||
type: 'button',
|
||||
authCode: 'System:Dept:Create',
|
||||
meta: { title: 'common.create' },
|
||||
},
|
||||
{
|
||||
id: 20_402,
|
||||
pid: 201,
|
||||
name: 'SystemDeptEdit',
|
||||
status: 1,
|
||||
type: 'button',
|
||||
authCode: 'System:Dept:Edit',
|
||||
meta: { title: 'common.edit' },
|
||||
},
|
||||
{
|
||||
id: 20_403,
|
||||
pid: 201,
|
||||
name: 'SystemDeptDelete',
|
||||
status: 1,
|
||||
type: 'button',
|
||||
authCode: 'System:Dept:Delete',
|
||||
meta: { title: 'common.delete' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
meta: {
|
||||
badgeType: 'dot',
|
||||
order: 9998,
|
||||
title: 'demos.vben.title',
|
||||
icon: 'carbon:data-center',
|
||||
},
|
||||
name: 'Project',
|
||||
path: '/vben-admin',
|
||||
type: 'catalog',
|
||||
status: 1,
|
||||
children: [
|
||||
{
|
||||
id: 901,
|
||||
pid: 9,
|
||||
name: 'VbenDocument',
|
||||
path: '/vben-admin/document',
|
||||
component: 'IFrameView',
|
||||
type: 'embedded',
|
||||
status: 1,
|
||||
meta: {
|
||||
icon: 'carbon:book',
|
||||
iframeSrc: 'https://doc.vben.pro',
|
||||
title: 'demos.vben.document',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 902,
|
||||
pid: 9,
|
||||
name: 'VbenGithub',
|
||||
path: '/vben-admin/github',
|
||||
component: 'IFrameView',
|
||||
type: 'link',
|
||||
status: 1,
|
||||
meta: {
|
||||
icon: 'carbon:logo-github',
|
||||
link: 'https://github.com/vbenjs/vue-vben-admin',
|
||||
title: 'Github',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 903,
|
||||
pid: 9,
|
||||
name: 'VbenAntdv',
|
||||
path: '/vben-admin/antdv',
|
||||
component: 'IFrameView',
|
||||
type: 'link',
|
||||
status: 0,
|
||||
meta: {
|
||||
icon: 'carbon:hexagon-vertical-solid',
|
||||
badgeType: 'dot',
|
||||
link: 'https://ant.vben.pro',
|
||||
title: 'demos.vben.antdv',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
component: '_core/about/index',
|
||||
type: 'menu',
|
||||
status: 1,
|
||||
meta: {
|
||||
icon: 'lucide:copyright',
|
||||
order: 9999,
|
||||
title: 'demos.vben.about',
|
||||
},
|
||||
name: 'About',
|
||||
path: '/about',
|
||||
},
|
||||
];
|
||||
|
||||
export function getMenuIds(menus: any[]) {
|
||||
const ids: number[] = [];
|
||||
menus.forEach((item) => {
|
||||
ids.push(item.id);
|
||||
if (item.children && item.children.length > 0) {
|
||||
ids.push(...getMenuIds(item.children));
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
PORT=5320
|
||||
PORT=5666
|
||||
ACCESS_TOKEN_SECRET=access_token_secret
|
||||
REFRESH_TOKEN_SECRET=refresh_token_secret
|
||||
@@ -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://<bot-host>:8889/webhook/transaction"
|
||||
export TELEGRAM_WEBHOOK_SECRET="自定义密钥"
|
||||
```
|
||||
|
||||
也可以使用旧变量 `FINANCE_BOT_WEBHOOK_URL`、`FINANCE_BOT_WEBHOOK_SECRET` 进行兼容配置。
|
||||
16
apps/backend/api/finance/accounts.get.ts
Normal file
16
apps/backend/api/finance/accounts.get.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getQuery } from 'h3';
|
||||
import { listAccounts } from '~/utils/finance-metadata';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const currency = query.currency as string | undefined;
|
||||
|
||||
let accounts = listAccounts();
|
||||
|
||||
if (currency) {
|
||||
accounts = accounts.filter((account) => account.currency === currency);
|
||||
}
|
||||
|
||||
return useResponseSuccess(accounts);
|
||||
});
|
||||
10
apps/backend/api/finance/budgets.get.ts
Normal file
10
apps/backend/api/finance/budgets.get.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineEventHandler } from '#nitro';
|
||||
|
||||
import { MOCK_BUDGETS } from '../../utils/mock-data';
|
||||
import { useResponseSuccess } from '../../utils/response';
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
// 返回未删除的预算
|
||||
const budgets = MOCK_BUDGETS.filter((b) => !b.isDeleted);
|
||||
return useResponseSuccess(budgets);
|
||||
});
|
||||
33
apps/backend/api/finance/budgets.post.ts
Normal file
33
apps/backend/api/finance/budgets.post.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineEventHandler, readBody } from '#nitro';
|
||||
|
||||
import { MOCK_BUDGETS } from '../../utils/mock-data';
|
||||
import { useResponseSuccess } from '../../utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
|
||||
const newBudget = {
|
||||
id: Date.now(),
|
||||
userId: 1,
|
||||
category: body.category,
|
||||
categoryId: body.categoryId,
|
||||
emoji: body.emoji,
|
||||
limit: body.limit,
|
||||
spent: body.spent || 0,
|
||||
remaining: body.remaining || body.limit,
|
||||
percentage: body.percentage || 0,
|
||||
currency: body.currency,
|
||||
period: body.period,
|
||||
alertThreshold: body.alertThreshold,
|
||||
description: body.description,
|
||||
autoRenew: body.autoRenew,
|
||||
overspendAlert: body.overspendAlert,
|
||||
dailyReminder: body.dailyReminder,
|
||||
monthlyTrend: body.monthlyTrend || 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
isDeleted: false,
|
||||
};
|
||||
|
||||
MOCK_BUDGETS.push(newBudget);
|
||||
return useResponseSuccess(newBudget);
|
||||
});
|
||||
22
apps/backend/api/finance/budgets/[id].delete.ts
Normal file
22
apps/backend/api/finance/budgets/[id].delete.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineEventHandler, getRouterParam } from '#nitro';
|
||||
|
||||
import { MOCK_BUDGETS } from '../../../utils/mock-data';
|
||||
import { useResponseError, useResponseSuccess } from '../../../utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
const index = MOCK_BUDGETS.findIndex((b) => b.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
return useResponseError('预算不存在', -1);
|
||||
}
|
||||
|
||||
// 软删除
|
||||
MOCK_BUDGETS[index] = {
|
||||
...MOCK_BUDGETS[index],
|
||||
isDeleted: true,
|
||||
deletedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return useResponseSuccess({ message: '删除成功' });
|
||||
});
|
||||
48
apps/backend/api/finance/budgets/[id].put.ts
Normal file
48
apps/backend/api/finance/budgets/[id].put.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { defineEventHandler, getRouterParam, readBody } from '#nitro';
|
||||
|
||||
import { MOCK_BUDGETS } from '../../../utils/mock-data';
|
||||
import { useResponseError, useResponseSuccess } from '../../../utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
const body = await readBody(event);
|
||||
|
||||
const index = MOCK_BUDGETS.findIndex((b) => b.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
return useResponseError('预算不存在', -1);
|
||||
}
|
||||
|
||||
// 如果是恢复操作
|
||||
if (body.isDeleted === false) {
|
||||
MOCK_BUDGETS[index] = {
|
||||
...MOCK_BUDGETS[index],
|
||||
isDeleted: false,
|
||||
deletedAt: undefined,
|
||||
};
|
||||
return useResponseSuccess(MOCK_BUDGETS[index]);
|
||||
}
|
||||
|
||||
// 普通更新
|
||||
const updatedBudget = {
|
||||
...MOCK_BUDGETS[index],
|
||||
category: body.category ?? MOCK_BUDGETS[index].category,
|
||||
categoryId: body.categoryId ?? MOCK_BUDGETS[index].categoryId,
|
||||
emoji: body.emoji ?? MOCK_BUDGETS[index].emoji,
|
||||
limit: body.limit ?? MOCK_BUDGETS[index].limit,
|
||||
spent: body.spent ?? MOCK_BUDGETS[index].spent,
|
||||
remaining: body.remaining ?? MOCK_BUDGETS[index].remaining,
|
||||
percentage: body.percentage ?? MOCK_BUDGETS[index].percentage,
|
||||
currency: body.currency ?? MOCK_BUDGETS[index].currency,
|
||||
period: body.period ?? MOCK_BUDGETS[index].period,
|
||||
alertThreshold: body.alertThreshold ?? MOCK_BUDGETS[index].alertThreshold,
|
||||
description: body.description ?? MOCK_BUDGETS[index].description,
|
||||
autoRenew: body.autoRenew ?? MOCK_BUDGETS[index].autoRenew,
|
||||
overspendAlert: body.overspendAlert ?? MOCK_BUDGETS[index].overspendAlert,
|
||||
dailyReminder: body.dailyReminder ?? MOCK_BUDGETS[index].dailyReminder,
|
||||
monthlyTrend: body.monthlyTrend ?? MOCK_BUDGETS[index].monthlyTrend,
|
||||
};
|
||||
|
||||
MOCK_BUDGETS[index] = updatedBudget;
|
||||
return useResponseSuccess(updatedBudget);
|
||||
});
|
||||
12
apps/backend/api/finance/categories.get.ts
Normal file
12
apps/backend/api/finance/categories.get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getQuery } from 'h3';
|
||||
import { fetchCategories } from '~/utils/finance-repository';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const type = query.type as 'expense' | 'income' | undefined;
|
||||
|
||||
const categories = fetchCategories({ type });
|
||||
|
||||
return useResponseSuccess(categories);
|
||||
});
|
||||
22
apps/backend/api/finance/categories.post.ts
Normal file
22
apps/backend/api/finance/categories.post.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { readBody } from 'h3';
|
||||
import { createCategoryRecord } from '~/utils/finance-metadata';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
|
||||
if (!body?.name || !body?.type) {
|
||||
return useResponseError('分类名称和类型为必填项', -1);
|
||||
}
|
||||
|
||||
const category = createCategoryRecord({
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
icon: body.icon,
|
||||
color: body.color,
|
||||
userId: 1,
|
||||
isActive: body.isActive ?? true,
|
||||
});
|
||||
|
||||
return useResponseSuccess(category);
|
||||
});
|
||||
17
apps/backend/api/finance/categories/[id].delete.ts
Normal file
17
apps/backend/api/finance/categories/[id].delete.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getRouterParam } from 'h3';
|
||||
import { deleteCategoryRecord } from '~/utils/finance-metadata';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (Number.isNaN(id)) {
|
||||
return useResponseError('参数错误', -1);
|
||||
}
|
||||
|
||||
const deleted = deleteCategoryRecord(id);
|
||||
if (!deleted) {
|
||||
return useResponseError('分类不存在', -1);
|
||||
}
|
||||
|
||||
return useResponseSuccess({ message: '删除成功' });
|
||||
});
|
||||
26
apps/backend/api/finance/categories/[id].put.ts
Normal file
26
apps/backend/api/finance/categories/[id].put.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getRouterParam, readBody } from 'h3';
|
||||
import { updateCategoryRecord } from '~/utils/finance-metadata';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (Number.isNaN(id)) {
|
||||
return useResponseError('参数错误', -1);
|
||||
}
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
const updated = updateCategoryRecord(id, {
|
||||
name: body?.name,
|
||||
icon: body?.icon,
|
||||
color: body?.color,
|
||||
userId: body?.userId,
|
||||
isActive: body?.isActive,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return useResponseError('分类不存在', -1);
|
||||
}
|
||||
|
||||
return useResponseSuccess(updated);
|
||||
});
|
||||
6
apps/backend/api/finance/currencies.get.ts
Normal file
6
apps/backend/api/finance/currencies.get.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { listCurrencies } from '~/utils/finance-metadata';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return useResponseSuccess(listCurrencies());
|
||||
});
|
||||
32
apps/backend/api/finance/exchange-rates.get.ts
Normal file
32
apps/backend/api/finance/exchange-rates.get.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getQuery } from 'h3';
|
||||
import { listExchangeRates } from '~/utils/finance-metadata';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const fromCurrency = query.from as string | undefined;
|
||||
const toCurrency = query.to as string | undefined;
|
||||
const date = query.date as string | undefined;
|
||||
|
||||
let rates = listExchangeRates();
|
||||
|
||||
if (fromCurrency) {
|
||||
rates = rates.filter((rate) => rate.fromCurrency === fromCurrency);
|
||||
}
|
||||
|
||||
if (toCurrency) {
|
||||
rates = rates.filter((rate) => rate.toCurrency === toCurrency);
|
||||
}
|
||||
|
||||
if (date) {
|
||||
rates = rates.filter((rate) => rate.date === date);
|
||||
} else if (rates.length > 0) {
|
||||
const latestDate = rates.reduce(
|
||||
(max, rate) => Math.max(rate.date, max),
|
||||
rates[0].date,
|
||||
);
|
||||
rates = rates.filter((rate) => rate.date === latestDate);
|
||||
}
|
||||
|
||||
return useResponseSuccess(rates);
|
||||
});
|
||||
29
apps/backend/api/finance/media.get.ts
Normal file
29
apps/backend/api/finance/media.get.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getQuery } from 'h3';
|
||||
|
||||
import { fetchMediaMessages } from '~/utils/media-repository';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event);
|
||||
const limit =
|
||||
typeof query.limit === 'string' && query.limit.length > 0
|
||||
? Number.parseInt(query.limit, 10)
|
||||
: undefined;
|
||||
const rawTypes = (query.types ?? query.type ?? query.fileType) as
|
||||
| string
|
||||
| undefined;
|
||||
const fileTypes = rawTypes
|
||||
? rawTypes
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
: undefined;
|
||||
|
||||
const messages = fetchMediaMessages({
|
||||
limit,
|
||||
fileTypes,
|
||||
});
|
||||
|
||||
return useResponseSuccess(messages);
|
||||
});
|
||||
|
||||
22
apps/backend/api/finance/media/[id].get.ts
Normal file
22
apps/backend/api/finance/media/[id].get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getRouterParam } from 'h3';
|
||||
|
||||
import { getMediaMessageById } from '~/utils/media-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const idParam = getRouterParam(event, 'id');
|
||||
const id = idParam ? Number.parseInt(idParam, 10) : NaN;
|
||||
|
||||
if (!Number.isInteger(id)) {
|
||||
return useResponseError('媒体ID不合法', -1);
|
||||
}
|
||||
|
||||
const media = getMediaMessageById(id);
|
||||
|
||||
if (!media) {
|
||||
return useResponseError('未找到对应的媒体记录', -1);
|
||||
}
|
||||
|
||||
return useResponseSuccess(media);
|
||||
});
|
||||
|
||||
46
apps/backend/api/finance/media/[id]/download.get.ts
Normal file
46
apps/backend/api/finance/media/[id]/download.get.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { basename } from 'pathe';
|
||||
|
||||
import {
|
||||
getRouterParam,
|
||||
sendStream,
|
||||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
} from 'h3';
|
||||
|
||||
import { getMediaMessageById } from '~/utils/media-repository';
|
||||
import { useResponseError } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const idParam = getRouterParam(event, 'id');
|
||||
const id = idParam ? Number.parseInt(idParam, 10) : NaN;
|
||||
|
||||
if (!Number.isInteger(id)) {
|
||||
setResponseStatus(event, 400);
|
||||
return useResponseError('媒体ID不合法', -1);
|
||||
}
|
||||
|
||||
const media = getMediaMessageById(id);
|
||||
if (!media) {
|
||||
setResponseStatus(event, 404);
|
||||
return useResponseError('未找到对应的媒体记录', -1);
|
||||
}
|
||||
|
||||
if (!media.filePath || !existsSync(media.filePath)) {
|
||||
setResponseStatus(event, 404);
|
||||
return useResponseError('媒体文件不存在或已被移除', -1);
|
||||
}
|
||||
|
||||
const fileStats = statSync(media.filePath);
|
||||
|
||||
setResponseHeader(event, 'Content-Type', media.mimeType ?? 'application/octet-stream');
|
||||
setResponseHeader(
|
||||
event,
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(media.fileName ?? basename(media.filePath))}"`,
|
||||
);
|
||||
setResponseHeader(event, 'Content-Length', `${fileStats.size}`);
|
||||
|
||||
return sendStream(event, createReadStream(media.filePath));
|
||||
});
|
||||
|
||||
35
apps/backend/api/finance/reimbursements.get.ts
Normal file
35
apps/backend/api/finance/reimbursements.get.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getQuery } from 'h3';
|
||||
import {
|
||||
fetchTransactions,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
const DEFAULT_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const includeDeleted = query.includeDeleted === 'true';
|
||||
const type = query.type as string | undefined;
|
||||
const rawStatuses = (query.statuses ?? query.status) as string | undefined;
|
||||
const statuses = rawStatuses
|
||||
? (rawStatuses
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0) as TransactionStatus[])
|
||||
: DEFAULT_STATUSES;
|
||||
|
||||
const reimbursements = fetchTransactions({
|
||||
includeDeleted,
|
||||
type,
|
||||
statuses,
|
||||
});
|
||||
|
||||
return useResponseSuccess(reimbursements);
|
||||
});
|
||||
73
apps/backend/api/finance/reimbursements.post.ts
Normal file
73
apps/backend/api/finance/reimbursements.post.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { readBody } from 'h3';
|
||||
import {
|
||||
createTransaction,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
|
||||
|
||||
const DEFAULT_CURRENCY = 'CNY';
|
||||
const DEFAULT_STATUS: TransactionStatus = 'pending';
|
||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
|
||||
if (!body?.amount || !body?.transactionDate) {
|
||||
return useResponseError('缺少必填字段', -1);
|
||||
}
|
||||
|
||||
const amount = Number(body.amount);
|
||||
if (Number.isNaN(amount)) {
|
||||
return useResponseError('金额格式不正确', -1);
|
||||
}
|
||||
|
||||
const type =
|
||||
(body.type as 'expense' | 'income' | 'transfer' | undefined) ?? 'expense';
|
||||
const status =
|
||||
(body.status as TransactionStatus | undefined) ?? DEFAULT_STATUS;
|
||||
|
||||
if (!ALLOWED_STATUSES.includes(status)) {
|
||||
return useResponseError('状态值不合法', -1);
|
||||
}
|
||||
|
||||
const reimbursement = createTransaction({
|
||||
type,
|
||||
amount,
|
||||
currency: body.currency ?? DEFAULT_CURRENCY,
|
||||
categoryId: body.categoryId ?? null,
|
||||
accountId: body.accountId ?? null,
|
||||
transactionDate: body.transactionDate,
|
||||
description:
|
||||
body.description ??
|
||||
body.item ??
|
||||
(body.notes ? `${body.notes}` : '') ??
|
||||
'',
|
||||
project: body.project ?? body.category ?? null,
|
||||
memo: body.memo ?? body.notes ?? null,
|
||||
status,
|
||||
reimbursementBatch: body.reimbursementBatch ?? null,
|
||||
reviewNotes: body.reviewNotes ?? null,
|
||||
submittedBy: body.submittedBy ?? body.requester ?? null,
|
||||
approvedBy: body.approvedBy ?? null,
|
||||
approvedAt: body.approvedAt ?? null,
|
||||
statusUpdatedAt: body.statusUpdatedAt ?? undefined,
|
||||
});
|
||||
|
||||
notifyTransactionWebhook(reimbursement, {
|
||||
action: 'reimbursement.created',
|
||||
}).catch((error) =>
|
||||
console.error(
|
||||
'[finance][reimbursements.post] webhook notify failed',
|
||||
error,
|
||||
),
|
||||
);
|
||||
|
||||
return useResponseSuccess(reimbursement);
|
||||
});
|
||||
85
apps/backend/api/finance/reimbursements/[id].put.ts
Normal file
85
apps/backend/api/finance/reimbursements/[id].put.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { getRouterParam, readBody } from 'h3';
|
||||
import {
|
||||
restoreTransaction,
|
||||
updateTransaction,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (Number.isNaN(id)) {
|
||||
return useResponseError('参数错误', -1);
|
||||
}
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
if (body?.isDeleted === false) {
|
||||
const restored = restoreTransaction(id);
|
||||
if (!restored) {
|
||||
return useResponseError('报销单不存在', -1);
|
||||
}
|
||||
return useResponseSuccess(restored);
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (body?.type) payload.type = body.type;
|
||||
if (body?.amount !== undefined) {
|
||||
const amount = Number(body.amount);
|
||||
if (Number.isNaN(amount)) {
|
||||
return useResponseError('金额格式不正确', -1);
|
||||
}
|
||||
payload.amount = amount;
|
||||
}
|
||||
if (body?.currency) payload.currency = body.currency;
|
||||
if (body?.categoryId !== undefined)
|
||||
payload.categoryId = body.categoryId ?? null;
|
||||
if (body?.accountId !== undefined) payload.accountId = body.accountId ?? null;
|
||||
if (body?.transactionDate) payload.transactionDate = body.transactionDate;
|
||||
if (body?.description !== undefined)
|
||||
payload.description = body.description ?? '';
|
||||
if (body?.project !== undefined) payload.project = body.project ?? null;
|
||||
if (body?.memo !== undefined) payload.memo = body.memo ?? null;
|
||||
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
|
||||
if (body?.status !== undefined) {
|
||||
const status = body.status as TransactionStatus;
|
||||
if (!ALLOWED_STATUSES.includes(status)) {
|
||||
return useResponseError('状态值不合法', -1);
|
||||
}
|
||||
payload.status = status;
|
||||
}
|
||||
if (body?.statusUpdatedAt !== undefined) {
|
||||
payload.statusUpdatedAt = body.statusUpdatedAt;
|
||||
}
|
||||
if (body?.reimbursementBatch !== undefined) {
|
||||
payload.reimbursementBatch = body.reimbursementBatch ?? null;
|
||||
}
|
||||
if (body?.reviewNotes !== undefined) {
|
||||
payload.reviewNotes = body.reviewNotes ?? null;
|
||||
}
|
||||
if (body?.submittedBy !== undefined) {
|
||||
payload.submittedBy = body.submittedBy ?? null;
|
||||
}
|
||||
if (body?.approvedBy !== undefined) {
|
||||
payload.approvedBy = body.approvedBy ?? null;
|
||||
}
|
||||
if (body?.approvedAt !== undefined) {
|
||||
payload.approvedAt = body.approvedAt ?? null;
|
||||
}
|
||||
|
||||
const updated = updateTransaction(id, payload);
|
||||
if (!updated) {
|
||||
return useResponseError('报销单不存在', -1);
|
||||
}
|
||||
|
||||
return useResponseSuccess(updated);
|
||||
});
|
||||
28
apps/backend/api/finance/transactions.get.ts
Normal file
28
apps/backend/api/finance/transactions.get.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getQuery } from 'h3';
|
||||
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 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);
|
||||
});
|
||||
60
apps/backend/api/finance/transactions.post.ts
Normal file
60
apps/backend/api/finance/transactions.post.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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 ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
|
||||
if (!body?.type || !body?.amount || !body?.transactionDate) {
|
||||
return useResponseError('缺少必填字段', -1);
|
||||
}
|
||||
|
||||
const amount = Number(body.amount);
|
||||
if (Number.isNaN(amount)) {
|
||||
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,
|
||||
currency: body.currency ?? DEFAULT_CURRENCY,
|
||||
categoryId: body.categoryId ?? null,
|
||||
accountId: body.accountId ?? null,
|
||||
transactionDate: body.transactionDate,
|
||||
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);
|
||||
});
|
||||
18
apps/backend/api/finance/transactions/[id].delete.ts
Normal file
18
apps/backend/api/finance/transactions/[id].delete.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getRouterParam } from 'h3';
|
||||
import { softDeleteTransaction } from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
|
||||
if (Number.isNaN(id)) {
|
||||
return useResponseError('参数错误', -1);
|
||||
}
|
||||
|
||||
const updated = softDeleteTransaction(id);
|
||||
if (!updated) {
|
||||
return useResponseError('交易不存在', -1);
|
||||
}
|
||||
|
||||
return useResponseSuccess({ message: '删除成功' });
|
||||
});
|
||||
85
apps/backend/api/finance/transactions/[id].put.ts
Normal file
85
apps/backend/api/finance/transactions/[id].put.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { getRouterParam, readBody } from 'h3';
|
||||
import {
|
||||
restoreTransaction,
|
||||
updateTransaction,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (Number.isNaN(id)) {
|
||||
return useResponseError('参数错误', -1);
|
||||
}
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
if (body?.isDeleted === false) {
|
||||
const restored = restoreTransaction(id);
|
||||
if (!restored) {
|
||||
return useResponseError('交易不存在', -1);
|
||||
}
|
||||
return useResponseSuccess(restored);
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (body?.type) payload.type = body.type;
|
||||
if (body?.amount !== undefined) {
|
||||
const amount = Number(body.amount);
|
||||
if (Number.isNaN(amount)) {
|
||||
return useResponseError('金额格式不正确', -1);
|
||||
}
|
||||
payload.amount = amount;
|
||||
}
|
||||
if (body?.currency) payload.currency = body.currency;
|
||||
if (body?.categoryId !== undefined)
|
||||
payload.categoryId = body.categoryId ?? null;
|
||||
if (body?.accountId !== undefined) payload.accountId = body.accountId ?? null;
|
||||
if (body?.transactionDate) payload.transactionDate = body.transactionDate;
|
||||
if (body?.description !== undefined)
|
||||
payload.description = body.description ?? '';
|
||||
if (body?.project !== undefined) payload.project = body.project ?? null;
|
||||
if (body?.memo !== undefined) payload.memo = body.memo ?? null;
|
||||
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
|
||||
if (body?.status !== undefined) {
|
||||
const status = body.status as TransactionStatus;
|
||||
if (!ALLOWED_STATUSES.includes(status)) {
|
||||
return useResponseError('状态值不合法', -1);
|
||||
}
|
||||
payload.status = status;
|
||||
}
|
||||
if (body?.statusUpdatedAt !== undefined) {
|
||||
payload.statusUpdatedAt = body.statusUpdatedAt;
|
||||
}
|
||||
if (body?.reimbursementBatch !== undefined) {
|
||||
payload.reimbursementBatch = body.reimbursementBatch ?? null;
|
||||
}
|
||||
if (body?.reviewNotes !== undefined) {
|
||||
payload.reviewNotes = body.reviewNotes ?? null;
|
||||
}
|
||||
if (body?.submittedBy !== undefined) {
|
||||
payload.submittedBy = body.submittedBy ?? null;
|
||||
}
|
||||
if (body?.approvedBy !== undefined) {
|
||||
payload.approvedBy = body.approvedBy ?? null;
|
||||
}
|
||||
if (body?.approvedAt !== undefined) {
|
||||
payload.approvedAt = body.approvedAt ?? null;
|
||||
}
|
||||
|
||||
const updated = updateTransaction(id, payload);
|
||||
if (!updated) {
|
||||
return useResponseError('交易不存在', -1);
|
||||
}
|
||||
|
||||
return useResponseSuccess(updated);
|
||||
});
|
||||
BIN
apps/backend/backend.tar.gz
Normal file
BIN
apps/backend/backend.tar.gz
Normal file
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@vben/backend-mock",
|
||||
"name": "@vben/backend",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"private": true,
|
||||
@@ -7,10 +7,12 @@
|
||||
"author": "",
|
||||
"scripts": {
|
||||
"build": "nitro build",
|
||||
"start": "nitro dev"
|
||||
"start": "nitro dev",
|
||||
"import:data": "node scripts/import-finance-data.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "catalog:",
|
||||
"better-sqlite3": "9.5.0",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"nitropack": "catalog:"
|
||||
},
|
||||
474
apps/backend/scripts/import-finance-data.js
Normal file
474
apps/backend/scripts/import-finance-data.js
Normal file
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const params = {};
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg.startsWith('--')) {
|
||||
const key = arg.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
params[key] = true;
|
||||
} else {
|
||||
params[key] = next;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.csv) {
|
||||
console.error('请通过 --csv <路径> 指定 CSV 数据文件');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputPath = path.resolve(params.csv);
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
console.error(`无法找到 CSV 文件: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseYear = params.year ? Number(params.year) : 2024;
|
||||
if (Number.isNaN(baseYear)) {
|
||||
console.error('参数 --year 必须为数字');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const storeDir = path.join(process.cwd(), 'storage');
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS finance_currencies (
|
||||
code TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
is_base INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_exchange_rates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_currency TEXT NOT NULL,
|
||||
to_currency TEXT NOT NULL,
|
||||
rate REAL NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
source TEXT DEFAULT 'manual'
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'cash',
|
||||
balance REAL DEFAULT 0,
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
is_active INTEGER DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
is_active INTEGER DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
exchange_rate_to_base REAL NOT NULL,
|
||||
amount_in_base REAL NOT NULL,
|
||||
category_id INTEGER,
|
||||
account_id INTEGER,
|
||||
transaction_date TEXT NOT NULL,
|
||||
description TEXT,
|
||||
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) {
|
||||
console.error('CSV 文件内容为空');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const header = lines[0].split(',');
|
||||
const DATE_IDX = header.indexOf('日期');
|
||||
const PROJECT_IDX = header.indexOf('项目');
|
||||
const TYPE_IDX = header.indexOf('收支');
|
||||
const AMOUNT_IDX = header.indexOf('金额');
|
||||
const ACCOUNT_IDX = header.indexOf('支出人');
|
||||
const CATEGORY_IDX = header.indexOf('计入');
|
||||
const SHARE_IDX = header.indexOf('阿德应得分红');
|
||||
|
||||
if (
|
||||
DATE_IDX === -1 ||
|
||||
PROJECT_IDX === -1 ||
|
||||
TYPE_IDX === -1 ||
|
||||
AMOUNT_IDX === -1 ||
|
||||
ACCOUNT_IDX === -1 ||
|
||||
CATEGORY_IDX === -1
|
||||
) {
|
||||
console.error('CSV 表头缺少必需字段');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const CURRENCIES = [
|
||||
{ code: 'CNY', name: '人民币', symbol: '¥', isBase: true },
|
||||
{ code: 'USD', name: '美元', symbol: '$', isBase: false },
|
||||
{ code: 'THB', name: '泰铢', symbol: '฿', isBase: false },
|
||||
];
|
||||
|
||||
const EXCHANGE_RATES = [
|
||||
{
|
||||
fromCurrency: 'CNY',
|
||||
toCurrency: 'CNY',
|
||||
rate: 1,
|
||||
date: `${baseYear}-01-01`,
|
||||
source: 'system',
|
||||
},
|
||||
{
|
||||
fromCurrency: 'USD',
|
||||
toCurrency: 'CNY',
|
||||
rate: 7.14,
|
||||
date: `${baseYear}-01-01`,
|
||||
source: 'manual',
|
||||
},
|
||||
{
|
||||
fromCurrency: 'THB',
|
||||
toCurrency: 'CNY',
|
||||
rate: 0.2,
|
||||
date: `${baseYear}-01-01`,
|
||||
source: 'manual',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_EXPENSE_CATEGORY = '未分类支出';
|
||||
const DEFAULT_INCOME_CATEGORY = '未分类收入';
|
||||
|
||||
db.prepare('DELETE FROM finance_transactions').run();
|
||||
db.prepare('DELETE FROM finance_accounts').run();
|
||||
db.prepare('DELETE FROM finance_categories').run();
|
||||
db.prepare('DELETE FROM finance_currencies').run();
|
||||
db.prepare('DELETE FROM finance_exchange_rates').run();
|
||||
|
||||
db.transaction(() => {
|
||||
const insertCurrency = db.prepare(`
|
||||
INSERT INTO finance_currencies (code, name, symbol, is_base, is_active)
|
||||
VALUES (@code, @name, @symbol, @isBase, 1)
|
||||
`);
|
||||
for (const currency of CURRENCIES) {
|
||||
insertCurrency.run({
|
||||
code: currency.code,
|
||||
name: currency.name,
|
||||
symbol: currency.symbol,
|
||||
isBase: currency.isBase ? 1 : 0,
|
||||
});
|
||||
}
|
||||
const insertRate = db.prepare(`
|
||||
INSERT INTO finance_exchange_rates (from_currency, to_currency, rate, date, source)
|
||||
VALUES (@fromCurrency, @toCurrency, @rate, @date, @source)
|
||||
`);
|
||||
for (const rate of EXCHANGE_RATES) {
|
||||
insertRate.run(rate);
|
||||
}
|
||||
})();
|
||||
|
||||
function inferCurrency(accountName, amountText) {
|
||||
const name = accountName ?? '';
|
||||
const text = `${name}${amountText ?? ''}`;
|
||||
const lower = text.toLowerCase();
|
||||
if (
|
||||
lower.includes('美金') ||
|
||||
lower.includes('usd') ||
|
||||
lower.includes('u$') ||
|
||||
lower.includes('u ')
|
||||
) {
|
||||
return 'USD';
|
||||
}
|
||||
if (lower.includes('泰铢') || lower.includes('thb')) {
|
||||
return 'THB';
|
||||
}
|
||||
return 'CNY';
|
||||
}
|
||||
|
||||
function parseAmount(raw) {
|
||||
if (!raw) return 0;
|
||||
const matches = String(raw)
|
||||
.replaceAll(/[^0-9.+-]/g, (char) =>
|
||||
char === '+' || char === '-' ? char : ' ',
|
||||
)
|
||||
.match(/[-+]?\d+(?:\.\d+)?/g);
|
||||
if (!matches) return 0;
|
||||
return matches.map(Number).reduce((sum, value) => sum + value, 0);
|
||||
}
|
||||
|
||||
function normalizeDate(value, monthTracker) {
|
||||
const cleaned = value.trim();
|
||||
const match = cleaned.match(/(\d{1,2})月(\d{1,2})日/);
|
||||
if (!match) {
|
||||
throw new Error(`无法解析日期: ${value}`);
|
||||
}
|
||||
const month = Number(match[1]);
|
||||
const day = Number(match[2]);
|
||||
let year = baseYear;
|
||||
if (
|
||||
monthTracker.lastMonth !== null &&
|
||||
month > monthTracker.lastMonth &&
|
||||
monthTracker.wrapped
|
||||
) {
|
||||
year -= 1;
|
||||
}
|
||||
if (
|
||||
monthTracker.lastMonth !== null &&
|
||||
month < monthTracker.lastMonth &&
|
||||
!monthTracker.wrapped
|
||||
) {
|
||||
monthTracker.wrapped = true;
|
||||
}
|
||||
monthTracker.lastMonth = month;
|
||||
const iso = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
return iso;
|
||||
}
|
||||
|
||||
const accountMap = new Map();
|
||||
const categoryMap = new Map();
|
||||
|
||||
const insertAccount = db.prepare(`
|
||||
INSERT INTO finance_accounts (name, currency, type, balance, icon, color, user_id, is_active)
|
||||
VALUES (@name, @currency, @type, 0, @icon, @color, 1, 1)
|
||||
`);
|
||||
|
||||
const insertCategory = db.prepare(`
|
||||
INSERT INTO finance_categories (name, type, icon, color, user_id, is_active)
|
||||
VALUES (@name, @type, @icon, @color, 1, 1)
|
||||
`);
|
||||
|
||||
db.transaction(() => {
|
||||
if (!categoryMap.has(`${DEFAULT_INCOME_CATEGORY}-income`)) {
|
||||
const info = insertCategory.run({
|
||||
name: DEFAULT_INCOME_CATEGORY,
|
||||
type: 'income',
|
||||
icon: '💰',
|
||||
color: '#10b981',
|
||||
});
|
||||
categoryMap.set(`${DEFAULT_INCOME_CATEGORY}-income`, info.lastInsertRowid);
|
||||
}
|
||||
if (!categoryMap.has(`${DEFAULT_EXPENSE_CATEGORY}-expense`)) {
|
||||
const info = insertCategory.run({
|
||||
name: DEFAULT_EXPENSE_CATEGORY,
|
||||
type: 'expense',
|
||||
icon: '🏷️',
|
||||
color: '#6366f1',
|
||||
});
|
||||
categoryMap.set(
|
||||
`${DEFAULT_EXPENSE_CATEGORY}-expense`,
|
||||
info.lastInsertRowid,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
const monthTracker = { lastMonth: null, wrapped: false };
|
||||
let carryDate = '';
|
||||
const transactions = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
const row = lines[i].split(',');
|
||||
while (row.length < header.length) row.push('');
|
||||
|
||||
const rawDate = row[DATE_IDX].trim();
|
||||
if (rawDate) {
|
||||
carryDate = normalizeDate(rawDate, monthTracker);
|
||||
}
|
||||
if (!carryDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const project = row[PROJECT_IDX].trim();
|
||||
const typeText = row[TYPE_IDX].trim();
|
||||
const amountRaw = row[AMOUNT_IDX].trim();
|
||||
const accountNameRaw = row[ACCOUNT_IDX].trim();
|
||||
const categoryRaw = row[CATEGORY_IDX].trim();
|
||||
const shareRaw = SHARE_IDX === -1 ? '' : row[SHARE_IDX].trim();
|
||||
|
||||
const amount = parseAmount(amountRaw);
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedType =
|
||||
typeText.includes('收') && !typeText.includes('支') ? 'income' : 'expense';
|
||||
const accountName = accountNameRaw || '美金现金';
|
||||
const currency = inferCurrency(accountNameRaw, amountRaw);
|
||||
|
||||
if (!accountMap.has(accountName)) {
|
||||
const icon = currency === 'USD' ? '💵' : currency === 'THB' ? '💱' : '💰';
|
||||
const color =
|
||||
currency === 'USD'
|
||||
? '#1677ff'
|
||||
: currency === 'THB'
|
||||
? '#22c55e'
|
||||
: '#6366f1';
|
||||
const info = insertAccount.run({
|
||||
name: accountName,
|
||||
currency,
|
||||
type: 'cash',
|
||||
icon,
|
||||
color,
|
||||
});
|
||||
accountMap.set(accountName, Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
const categoryName =
|
||||
categoryRaw ||
|
||||
(normalizedType === 'income'
|
||||
? DEFAULT_INCOME_CATEGORY
|
||||
: DEFAULT_EXPENSE_CATEGORY);
|
||||
const categoryKey = `${categoryName}-${normalizedType}`;
|
||||
if (!categoryMap.has(categoryKey)) {
|
||||
const icon = normalizedType === 'income' ? '💰' : '🏷️';
|
||||
const color = normalizedType === 'income' ? '#10b981' : '#fb7185';
|
||||
const info = insertCategory.run({
|
||||
name: categoryName,
|
||||
type: normalizedType,
|
||||
icon,
|
||||
color,
|
||||
});
|
||||
categoryMap.set(categoryKey, Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
const descriptionParts = [];
|
||||
if (project) descriptionParts.push(project);
|
||||
if (categoryRaw) descriptionParts.push(`计入: ${categoryRaw}`);
|
||||
if (shareRaw) descriptionParts.push(`分红: ${shareRaw}`);
|
||||
|
||||
const description = descriptionParts.join(' | ');
|
||||
transactions.push({
|
||||
type: normalizedType,
|
||||
amount,
|
||||
currency,
|
||||
categoryId: categoryMap.get(categoryKey) ?? null,
|
||||
accountId: accountMap.get(accountName) ?? null,
|
||||
transactionDate: carryDate,
|
||||
description,
|
||||
project: project || null,
|
||||
memo: shareRaw || null,
|
||||
});
|
||||
}
|
||||
|
||||
const insertTransaction = 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)
|
||||
`);
|
||||
|
||||
const getRateStmt = db.prepare(`
|
||||
SELECT rate
|
||||
FROM finance_exchange_rates
|
||||
WHERE from_currency = ? AND to_currency = 'CNY'
|
||||
ORDER BY date DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const insertMany = db.transaction((items) => {
|
||||
for (const item of items) {
|
||||
const rateRow = getRateStmt.get(item.currency);
|
||||
const rate = rateRow ? rateRow.rate : 1;
|
||||
const amountInBase = +(item.amount * rate).toFixed(2);
|
||||
insertTransaction.run({
|
||||
...item,
|
||||
exchangeRateToBase: rate,
|
||||
amountInBase,
|
||||
createdAt: `${item.transactionDate}T00:00:00.000Z`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(transactions);
|
||||
|
||||
console.log(
|
||||
`已导入 ${transactions.length} 条交易,账户 ${accountMap.size} 个,分类 ${categoryMap.size} 个。`,
|
||||
);
|
||||
130
apps/backend/utils/finance-metadata.ts
Normal file
130
apps/backend/utils/finance-metadata.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
MOCK_ACCOUNTS,
|
||||
MOCK_BUDGETS,
|
||||
MOCK_CATEGORIES,
|
||||
MOCK_CURRENCIES,
|
||||
MOCK_EXCHANGE_RATES,
|
||||
} from './mock-data';
|
||||
import db from './sqlite';
|
||||
|
||||
export function listAccounts() {
|
||||
return MOCK_ACCOUNTS;
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
// 从数据库读取分类
|
||||
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() {
|
||||
return MOCK_BUDGETS;
|
||||
}
|
||||
|
||||
export function listCurrencies() {
|
||||
return MOCK_CURRENCIES;
|
||||
}
|
||||
|
||||
export function listExchangeRates() {
|
||||
return MOCK_EXCHANGE_RATES;
|
||||
}
|
||||
|
||||
export function createCategoryRecord(category: any) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteCategoryRecord(id: number) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
396
apps/backend/utils/finance-repository.ts
Normal file
396
apps/backend/utils/finance-repository.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import db from './sqlite';
|
||||
|
||||
const BASE_CURRENCY = 'CNY';
|
||||
|
||||
interface TransactionRow {
|
||||
id: number;
|
||||
type: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
exchange_rate_to_base: number;
|
||||
amount_in_base: number;
|
||||
category_id: null | number;
|
||||
account_id: null | number;
|
||||
transaction_date: string;
|
||||
description: null | string;
|
||||
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;
|
||||
}
|
||||
|
||||
interface TransactionPayload {
|
||||
type: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
categoryId?: null | number;
|
||||
accountId?: null | number;
|
||||
transactionDate: string;
|
||||
description?: string;
|
||||
project?: null | string;
|
||||
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;
|
||||
}
|
||||
const stmt = db.prepare(
|
||||
`SELECT rate FROM finance_exchange_rates WHERE from_currency = ? AND to_currency = ? ORDER BY date DESC LIMIT 1`,
|
||||
);
|
||||
const row = stmt.get(currency, BASE_CURRENCY) as undefined | { rate: number };
|
||||
return row?.rate ?? 1;
|
||||
}
|
||||
|
||||
function mapTransaction(row: TransactionRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: 1,
|
||||
type: 'expense' as const,
|
||||
amount: Math.abs(row.amount),
|
||||
currency: row.currency,
|
||||
exchangeRateToBase: row.exchange_rate_to_base,
|
||||
amountInBase: Math.abs(row.amount_in_base),
|
||||
categoryId: row.category_id ?? undefined,
|
||||
accountId: row.account_id ?? undefined,
|
||||
transactionDate: row.transaction_date,
|
||||
description: row.description ?? '',
|
||||
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;
|
||||
statuses?: TransactionStatus[];
|
||||
} = {},
|
||||
) {
|
||||
const clauses: string[] = [];
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
if (!options.includeDeleted) {
|
||||
clauses.push('is_deleted = 0');
|
||||
}
|
||||
if (options.type) {
|
||||
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<TransactionRow>(
|
||||
`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);
|
||||
}
|
||||
|
||||
export function getTransactionById(id: number) {
|
||||
const stmt = db.prepare<TransactionRow>(
|
||||
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, 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;
|
||||
}
|
||||
|
||||
export function createTransaction(payload: TransactionPayload) {
|
||||
const exchangeRate = getExchangeRateToBase(payload.currency);
|
||||
const amountInBase = +(payload.amount * exchangeRate).toFixed(2);
|
||||
const createdAt =
|
||||
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, 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({
|
||||
type: payload.type,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency,
|
||||
exchangeRateToBase: exchangeRate,
|
||||
amountInBase,
|
||||
categoryId: payload.categoryId ?? null,
|
||||
accountId: payload.accountId ?? null,
|
||||
transactionDate: payload.transactionDate,
|
||||
description: payload.description ?? '',
|
||||
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));
|
||||
}
|
||||
|
||||
export function updateTransaction(id: number, payload: TransactionPayload) {
|
||||
const current = getTransactionById(id);
|
||||
if (!current) {
|
||||
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,
|
||||
currency: payload.currency ?? current.currency,
|
||||
categoryId: payload.categoryId ?? current.categoryId ?? null,
|
||||
accountId: payload.accountId ?? current.accountId ?? null,
|
||||
transactionDate: payload.transactionDate ?? current.transactionDate,
|
||||
description: payload.description ?? current.description ?? '',
|
||||
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, 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;
|
||||
|
||||
stmt.run({
|
||||
id,
|
||||
type: next.type,
|
||||
amount: next.amount,
|
||||
currency: next.currency,
|
||||
exchangeRateToBase: exchangeRate,
|
||||
amountInBase,
|
||||
categoryId: next.categoryId,
|
||||
accountId: next.accountId,
|
||||
transactionDate: next.transactionDate,
|
||||
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,
|
||||
});
|
||||
|
||||
return getTransactionById(id);
|
||||
}
|
||||
|
||||
export function softDeleteTransaction(id: number) {
|
||||
const stmt = db.prepare(
|
||||
`UPDATE finance_transactions SET is_deleted = 1, deleted_at = @deletedAt WHERE id = @id`,
|
||||
);
|
||||
stmt.run({ id, deletedAt: new Date().toISOString() });
|
||||
return getTransactionById(id);
|
||||
}
|
||||
|
||||
export function restoreTransaction(id: number) {
|
||||
const stmt = db.prepare(
|
||||
`UPDATE finance_transactions SET is_deleted = 0, deleted_at = NULL WHERE id = @id`,
|
||||
);
|
||||
stmt.run({ id });
|
||||
return getTransactionById(id);
|
||||
}
|
||||
|
||||
export function replaceAllTransactions(
|
||||
rows: Array<{
|
||||
accountId: null | number;
|
||||
amount: number;
|
||||
categoryId: null | number;
|
||||
createdAt?: string;
|
||||
currency: string;
|
||||
description: string;
|
||||
memo?: null | string;
|
||||
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, 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(
|
||||
`SELECT rate FROM finance_exchange_rates WHERE from_currency = ? AND to_currency = 'CNY' ORDER BY date DESC LIMIT 1`,
|
||||
);
|
||||
|
||||
const insertMany = db.transaction((items: Array<any>) => {
|
||||
for (const item of items) {
|
||||
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,
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(rows);
|
||||
}
|
||||
|
||||
// 分类相关函数
|
||||
interface CategoryRow {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
icon: null | string;
|
||||
color: null | string;
|
||||
user_id: null | number;
|
||||
is_active: number;
|
||||
}
|
||||
|
||||
function mapCategory(row: CategoryRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id ?? null,
|
||||
name: row.name,
|
||||
type: row.type as 'expense' | 'income',
|
||||
icon: row.icon ?? '📝',
|
||||
color: row.color ?? '#dfe4ea',
|
||||
sortOrder: row.id,
|
||||
isSystem: row.user_id === null,
|
||||
isActive: Boolean(row.is_active),
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCategories(options: { type?: 'expense' | 'income' } = {}) {
|
||||
const where = options.type
|
||||
? `WHERE type = @type AND is_active = 1`
|
||||
: 'WHERE is_active = 1';
|
||||
const params = options.type ? { type: options.type } : {};
|
||||
|
||||
const stmt = db.prepare<CategoryRow>(
|
||||
`SELECT id, name, type, icon, color, user_id, is_active FROM finance_categories ${where} ORDER BY id ASC`,
|
||||
);
|
||||
|
||||
return stmt.all(params).map(mapCategory);
|
||||
}
|
||||
117
apps/backend/utils/media-repository.ts
Normal file
117
apps/backend/utils/media-repository.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import db from './sqlite';
|
||||
|
||||
interface MediaRow {
|
||||
id: number;
|
||||
chat_id: number;
|
||||
message_id: number;
|
||||
user_id: number;
|
||||
username: null | string;
|
||||
display_name: null | string;
|
||||
file_type: string;
|
||||
file_id: string;
|
||||
file_unique_id: null | string;
|
||||
caption: null | string;
|
||||
file_name: null | string;
|
||||
file_path: string;
|
||||
file_size: null | number;
|
||||
mime_type: null | string;
|
||||
duration: null | number;
|
||||
width: null | number;
|
||||
height: null | number;
|
||||
forwarded_to: null | number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MediaMessage {
|
||||
id: number;
|
||||
chatId: number;
|
||||
messageId: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
fileType: string;
|
||||
fileId: string;
|
||||
fileUniqueId?: string;
|
||||
caption?: string;
|
||||
fileName?: string;
|
||||
filePath: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
duration?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
forwardedTo?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
available: boolean;
|
||||
downloadUrl: string | null;
|
||||
}
|
||||
|
||||
function mapMediaRow(row: MediaRow): MediaMessage {
|
||||
const fileExists = existsSync(row.file_path);
|
||||
return {
|
||||
id: row.id,
|
||||
chatId: row.chat_id,
|
||||
messageId: row.message_id,
|
||||
userId: row.user_id,
|
||||
username: row.username ?? undefined,
|
||||
displayName: row.display_name ?? undefined,
|
||||
fileType: row.file_type,
|
||||
fileId: row.file_id,
|
||||
fileUniqueId: row.file_unique_id ?? undefined,
|
||||
caption: row.caption ?? undefined,
|
||||
fileName: row.file_name ?? undefined,
|
||||
filePath: row.file_path,
|
||||
fileSize: row.file_size ?? undefined,
|
||||
mimeType: row.mime_type ?? undefined,
|
||||
duration: row.duration ?? undefined,
|
||||
width: row.width ?? undefined,
|
||||
height: row.height ?? undefined,
|
||||
forwardedTo: row.forwarded_to ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
available: fileExists,
|
||||
downloadUrl: fileExists ? `/finance/media/${row.id}/download` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchMediaMessages(params: {
|
||||
limit?: number;
|
||||
fileTypes?: string[];
|
||||
} = {}) {
|
||||
const clauses: string[] = [];
|
||||
const bindParams: Record<string, unknown> = {};
|
||||
|
||||
if (params.fileTypes && params.fileTypes.length > 0) {
|
||||
clauses.push(
|
||||
`file_type IN (${params.fileTypes.map((_, index) => `@type${index}`).join(', ')})`,
|
||||
);
|
||||
params.fileTypes.forEach((type, index) => {
|
||||
bindParams[`type${index}`] = type;
|
||||
});
|
||||
}
|
||||
|
||||
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const limitClause =
|
||||
params.limit && params.limit > 0 ? `LIMIT ${Number(params.limit)}` : '';
|
||||
|
||||
const stmt = db.prepare<MediaRow>(
|
||||
`SELECT id, chat_id, message_id, user_id, username, display_name, file_type, file_id, file_unique_id, caption, file_name, file_path, file_size, mime_type, duration, width, height, forwarded_to, created_at, updated_at FROM finance_media_messages ${where} ORDER BY datetime(created_at) DESC, id DESC ${limitClause}`,
|
||||
);
|
||||
|
||||
return stmt.all(bindParams).map(mapMediaRow);
|
||||
}
|
||||
|
||||
export function getMediaMessageById(id: number) {
|
||||
const stmt = db.prepare<MediaRow>(
|
||||
`SELECT id, chat_id, message_id, user_id, username, display_name, file_type, file_id, file_unique_id, caption, file_name, file_path, file_size, mime_type, duration, width, height, forwarded_to, created_at, updated_at FROM finance_media_messages WHERE id = ?`,
|
||||
);
|
||||
|
||||
const row = stmt.get(id);
|
||||
|
||||
return row ? mapMediaRow(row) : null;
|
||||
}
|
||||
|
||||
15
apps/backend/utils/media-storage.ts
Normal file
15
apps/backend/utils/media-storage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'pathe';
|
||||
|
||||
const MEDIA_ROOT = join(process.cwd(), 'storage', 'telegram-media');
|
||||
|
||||
mkdirSync(MEDIA_ROOT, { recursive: true });
|
||||
|
||||
export function getMediaRoot() {
|
||||
return MEDIA_ROOT;
|
||||
}
|
||||
|
||||
export function resolveMediaAbsolutePath(relativePath: string) {
|
||||
return join(MEDIA_ROOT, relativePath);
|
||||
}
|
||||
|
||||
1550
apps/backend/utils/mock-data.ts
Normal file
1550
apps/backend/utils/mock-data.ts
Normal file
File diff suppressed because it is too large
Load Diff
160
apps/backend/utils/sqlite.ts
Normal file
160
apps/backend/utils/sqlite.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { dirname, join } from 'pathe';
|
||||
|
||||
const dbFile = join(process.cwd(), 'storage', 'finance.db');
|
||||
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS finance_currencies (
|
||||
code TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
is_base INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_exchange_rates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_currency TEXT NOT NULL,
|
||||
to_currency TEXT NOT NULL,
|
||||
rate REAL NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
source TEXT DEFAULT 'manual'
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'cash',
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
is_active INTEGER DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
is_active INTEGER DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
exchange_rate_to_base REAL NOT NULL,
|
||||
amount_in_base REAL NOT NULL,
|
||||
category_id INTEGER,
|
||||
account_id INTEGER,
|
||||
transaction_date TEXT NOT NULL,
|
||||
description TEXT,
|
||||
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),
|
||||
FOREIGN KEY (category_id) REFERENCES finance_categories(id),
|
||||
FOREIGN KEY (account_id) REFERENCES finance_accounts(id)
|
||||
);
|
||||
`);
|
||||
|
||||
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;
|
||||
73
apps/backend/utils/telegram-webhook.ts
Normal file
73
apps/backend/utils/telegram-webhook.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
const DEFAULT_WEBHOOK_URL =
|
||||
process.env.TELEGRAM_WEBHOOK_URL ??
|
||||
process.env.FINANCE_BOT_WEBHOOK_URL ??
|
||||
'http://192.168.9.28:8889/webhook/transaction';
|
||||
const DEFAULT_WEBHOOK_SECRET =
|
||||
process.env.TELEGRAM_WEBHOOK_SECRET ??
|
||||
process.env.FINANCE_BOT_WEBHOOK_SECRET ??
|
||||
'ktapp.cc';
|
||||
|
||||
interface TransactionPayload {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function postToWebhook(
|
||||
payload: TransactionPayload,
|
||||
webhookURL: string,
|
||||
webhookSecret: string,
|
||||
) {
|
||||
try {
|
||||
const response = await fetch(webhookURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${webhookSecret}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
console.error(
|
||||
'[telegram-webhook] Failed to notify webhook',
|
||||
response.status,
|
||||
text,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[telegram-webhook] Webhook request error', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyTransactionWebhook(
|
||||
transaction: TransactionPayload,
|
||||
options: {
|
||||
webhookURL?: string;
|
||||
webhookSecret?: string;
|
||||
action?: string;
|
||||
source?: string;
|
||||
} = {},
|
||||
) {
|
||||
const url = (options.webhookURL ?? DEFAULT_WEBHOOK_URL).trim();
|
||||
const secret = (options.webhookSecret ?? DEFAULT_WEBHOOK_SECRET).trim();
|
||||
|
||||
if (!url || !secret) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TransactionPayload = {
|
||||
...transaction,
|
||||
};
|
||||
|
||||
if (options.action) {
|
||||
payload.action = options.action;
|
||||
}
|
||||
|
||||
if (options.source) {
|
||||
payload.source = options.source;
|
||||
} else {
|
||||
payload.source = payload.source ?? 'finwise-backend';
|
||||
}
|
||||
|
||||
await postToWebhook(payload, url, secret);
|
||||
}
|
||||
56
apps/finance-mcp-service/README.md
Normal file
56
apps/finance-mcp-service/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Finwise Finance MCP Service
|
||||
|
||||
该包将 Finwise Pro 的 `/api/finance/*` 接口封装为 Model Context Protocol (MCP) 工具,方便 Codex、Claude 等 MCP 客户端直接调用财务能力。
|
||||
|
||||
## 使用步骤
|
||||
|
||||
1. 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. 构建服务
|
||||
|
||||
```bash
|
||||
(本服务为纯 Node.js 实现,如无额外需求可跳过构建)
|
||||
```
|
||||
|
||||
3. 启动服务(示例)
|
||||
|
||||
```bash
|
||||
FINANCE_BASIC_USERNAME=atai \
|
||||
FINANCE_BASIC_PASSWORD=wengewudi666808 \
|
||||
node apps/finance-mcp-service/src/index.js
|
||||
```
|
||||
|
||||
可选环境变量:
|
||||
|
||||
| 变量 | 含义 |
|
||||
| --- | --- |
|
||||
| `FINANCE_API_BASE_URL` | 默认 `http://172.16.74.149:5666`,如需变更可重设。 |
|
||||
| `FINANCE_API_KEY` | 将作为 Bearer Token 附加在请求头。 |
|
||||
| `FINANCE_API_TIMEOUT` | 请求超时(毫秒)。 |
|
||||
| `FINANCE_BASIC_USERNAME` / `FINANCE_BASIC_PASSWORD` | 使用 HTTP Basic Auth 访问后端。 |
|
||||
|
||||
如需在 Codex 中自动启动该 MCP 服务,可在 `config.json` 中加入以下配置片段(路径默认位于 `~/.config/codex/config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"finwise-finance": {
|
||||
"command": "node",
|
||||
"args": ["apps/finance-mcp-service/src/index.js"],
|
||||
"env": {
|
||||
"FINANCE_BASIC_USERNAME": "atai",
|
||||
"FINANCE_BASIC_PASSWORD": "wengewudi666808"
|
||||
},
|
||||
"cwd": "/Users/fuwuqi/Projects/web-apps/finwise-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
配置完成后,重启 Codex 即可在 MCP 面板中看到 `finwise-finance`,并通过工具调用各类财务接口。
|
||||
|
||||
工具清单与入参定义详见 `src/index.ts`。
|
||||
12
apps/finance-mcp-service/package.json
Normal file
12
apps/finance-mcp-service/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@vben/finance-mcp-service",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "MCP service exposing Finwise Pro finance APIs",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {}
|
||||
}
|
||||
9
apps/finance-mcp-service/pnpm-lock.yaml
generated
Normal file
9
apps/finance-mcp-service/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
285
apps/finance-mcp-service/src/finance-client.js
Normal file
285
apps/finance-mcp-service/src/finance-client.js
Normal file
@@ -0,0 +1,285 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
export class FinanceClient {
|
||||
constructor(config) {
|
||||
if (!config?.baseUrl) {
|
||||
throw new Error('FinanceClient requires a baseUrl');
|
||||
}
|
||||
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||
this.apiKey = config.apiKey;
|
||||
this.basicAuth = validateBasicAuth(config.basicAuth);
|
||||
this.timeoutMs = config.timeoutMs;
|
||||
}
|
||||
|
||||
async listAccounts(params = {}) {
|
||||
return this.get('/api/finance/accounts', params);
|
||||
}
|
||||
|
||||
async listBudgets() {
|
||||
return this.get('/api/finance/budgets');
|
||||
}
|
||||
|
||||
async createBudget(payload) {
|
||||
return this.post('/api/finance/budgets', payload);
|
||||
}
|
||||
|
||||
async updateBudget(id, payload) {
|
||||
return this.put(`/api/finance/budgets/${id}`, payload);
|
||||
}
|
||||
|
||||
async deleteBudget(id) {
|
||||
return this.delete(`/api/finance/budgets/${id}`);
|
||||
}
|
||||
|
||||
async listCategories(params = {}) {
|
||||
return this.get('/api/finance/categories', params);
|
||||
}
|
||||
|
||||
async createCategory(payload) {
|
||||
return this.post('/api/finance/categories', payload);
|
||||
}
|
||||
|
||||
async updateCategory(id, payload) {
|
||||
return this.put(`/api/finance/categories/${id}`, payload);
|
||||
}
|
||||
|
||||
async deleteCategory(id) {
|
||||
return this.delete(`/api/finance/categories/${id}`);
|
||||
}
|
||||
|
||||
async listCurrencies() {
|
||||
return this.get('/api/finance/currencies');
|
||||
}
|
||||
|
||||
async listExchangeRates(params = {}) {
|
||||
const query = {};
|
||||
if (params.fromCurrency) query.from = params.fromCurrency;
|
||||
if (params.toCurrency) query.to = params.toCurrency;
|
||||
if (params.date) query.date = params.date;
|
||||
return this.get('/api/finance/exchange-rates', query);
|
||||
}
|
||||
|
||||
async listTransactions(params = {}) {
|
||||
const query = {};
|
||||
if (params.type) query.type = params.type;
|
||||
if (params.statuses?.length) {
|
||||
query.statuses = params.statuses.join(',');
|
||||
}
|
||||
if (params.includeDeleted !== undefined) {
|
||||
query.includeDeleted = params.includeDeleted;
|
||||
}
|
||||
return this.get('/api/finance/transactions', query);
|
||||
}
|
||||
|
||||
async createTransaction(payload) {
|
||||
return this.post('/api/finance/transactions', payload);
|
||||
}
|
||||
|
||||
async updateTransaction(id, payload) {
|
||||
return this.put(`/api/finance/transactions/${id}`, payload);
|
||||
}
|
||||
|
||||
async deleteTransaction(id) {
|
||||
return this.delete(`/api/finance/transactions/${id}`);
|
||||
}
|
||||
|
||||
async listReimbursements(params = {}) {
|
||||
const query = {};
|
||||
if (params.type) query.type = params.type;
|
||||
if (params.statuses?.length) {
|
||||
query.statuses = params.statuses.join(',');
|
||||
}
|
||||
if (params.includeDeleted !== undefined) {
|
||||
query.includeDeleted = params.includeDeleted;
|
||||
}
|
||||
return this.get('/api/finance/reimbursements', query);
|
||||
}
|
||||
|
||||
async createReimbursement(payload) {
|
||||
return this.post('/api/finance/reimbursements', payload);
|
||||
}
|
||||
|
||||
async updateReimbursement(id, payload) {
|
||||
return this.put(`/api/finance/reimbursements/${id}`, payload);
|
||||
}
|
||||
|
||||
async listMedia(params = {}) {
|
||||
const query = {};
|
||||
if (params.limit !== undefined) query.limit = params.limit;
|
||||
if (params.fileTypes?.length) query.types = params.fileTypes.join(',');
|
||||
return this.get('/api/finance/media', query);
|
||||
}
|
||||
|
||||
async getMediaById(id) {
|
||||
return this.get(`/api/finance/media/${id}`);
|
||||
}
|
||||
|
||||
async downloadMedia(id) {
|
||||
const url = this.createUrl(`/api/finance/media/${id}/download`);
|
||||
const response = await this.performFetch(url, {
|
||||
method: 'GET',
|
||||
headers: this.buildHeaders(false),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await this.safeParseEnvelope(response);
|
||||
if (payload) {
|
||||
throw new Error(payload.message || 'Failed to download media file');
|
||||
}
|
||||
throw new Error(`Failed to download media file (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
return {
|
||||
fileName: this.extractFileName(response.headers.get('content-disposition')),
|
||||
mimeType: response.headers.get('content-type') ?? 'application/octet-stream',
|
||||
size: buffer.byteLength,
|
||||
base64: buffer.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
async get(path, query) {
|
||||
return this.request('GET', path, { query });
|
||||
}
|
||||
|
||||
async post(path, body) {
|
||||
return this.request('POST', path, { body });
|
||||
}
|
||||
|
||||
async put(path, body) {
|
||||
return this.request('PUT', path, { body });
|
||||
}
|
||||
|
||||
async delete(path) {
|
||||
return this.request('DELETE', path);
|
||||
}
|
||||
|
||||
async request(method, path, options = {}) {
|
||||
const url = this.createUrl(path);
|
||||
|
||||
if (options.query) {
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) url.searchParams.set(key, value.join(','));
|
||||
} else if (typeof value === 'boolean') {
|
||||
url.searchParams.set(key, value ? 'true' : 'false');
|
||||
} else {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.performFetch(url, {
|
||||
method,
|
||||
headers: this.buildHeaders(method !== 'GET' && method !== 'DELETE'),
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
const payload = await this.parseEnvelope(response, path);
|
||||
|
||||
if (payload.code !== 0) {
|
||||
throw new Error(payload.message || 'Finance API returned an error');
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
createUrl(path) {
|
||||
if (!path.startsWith('/')) {
|
||||
path = `/${path}`;
|
||||
}
|
||||
return new URL(path, this.baseUrl);
|
||||
}
|
||||
|
||||
buildHeaders(json) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (json) headers['Content-Type'] = 'application/json';
|
||||
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
|
||||
else if (this.basicAuth) headers.Authorization = `Basic ${createBasicToken(this.basicAuth)}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async performFetch(url, init) {
|
||||
const controller = this.timeoutMs ? new AbortController() : undefined;
|
||||
let timer;
|
||||
|
||||
if (controller) {
|
||||
init.signal = controller.signal;
|
||||
timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`Request to ${url.pathname} timed out`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async parseEnvelope(response, path) {
|
||||
const payload = await this.safeParseEnvelope(response);
|
||||
|
||||
if (!payload) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Unexpected response from ${path}: ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || `Finance API request failed (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async safeParseEnvelope(response) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) return null;
|
||||
|
||||
try {
|
||||
return await response.clone().json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extractFileName(contentDisposition) {
|
||||
if (!contentDisposition) return undefined;
|
||||
|
||||
const filenameStar = contentDisposition.match(/filename\*=([^;]+)/i);
|
||||
if (filenameStar?.[1]) {
|
||||
const value = filenameStar[1].replace(/^UTF-8''/, '');
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const filename = contentDisposition.match(/filename="?([^";]+)"?/i);
|
||||
return filename?.[1];
|
||||
}
|
||||
}
|
||||
|
||||
const validateBasicAuth = (credentials) => {
|
||||
if (!credentials) return undefined;
|
||||
const username = credentials.username ?? credentials.user ?? credentials.login;
|
||||
const password = credentials.password ?? credentials.pass;
|
||||
|
||||
if (!username && !password) return undefined;
|
||||
if (!username || !password) {
|
||||
throw new Error('FinanceClient basicAuth requires both username and password');
|
||||
}
|
||||
|
||||
return { username: String(username), password: String(password) };
|
||||
};
|
||||
|
||||
const createBasicToken = ({ username, password }) =>
|
||||
Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
|
||||
901
apps/finance-mcp-service/src/index.js
Normal file
901
apps/finance-mcp-service/src/index.js
Normal file
@@ -0,0 +1,901 @@
|
||||
import process from 'node:process';
|
||||
|
||||
import { FinanceClient } from './finance-client.js';
|
||||
|
||||
process.on('exit', (code) => {
|
||||
process.stderr.write(`[finwise-finance] process exit with code ${code}\n`);
|
||||
});
|
||||
process.on('uncaughtException', (error) => {
|
||||
process.stderr.write(`[finwise-finance] uncaughtException: ${error.stack ?? error.message}\n`);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
process.stderr.write(`[finwise-finance] unhandledRejection: ${reason}\n`);
|
||||
});
|
||||
|
||||
class McpServer {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.tools = new Map();
|
||||
this.metadata = [];
|
||||
this.buffer = '';
|
||||
this.expectedLength = null;
|
||||
this.initialized = false;
|
||||
|
||||
for (const tool of options.tools) {
|
||||
if (this.tools.has(tool.name)) {
|
||||
throw new Error(`Duplicate MCP tool name: ${tool.name}`);
|
||||
}
|
||||
this.tools.set(tool.name, tool);
|
||||
this.metadata.push({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
this.buffer += chunk;
|
||||
void this.drain();
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
this.log('stdin ended');
|
||||
});
|
||||
process.stdin.on('close', () => {
|
||||
this.log('stdin closed');
|
||||
});
|
||||
process.stdin.resume();
|
||||
this.log('MCP service ready');
|
||||
}
|
||||
|
||||
write(payload) {
|
||||
const json = JSON.stringify(payload);
|
||||
const frame = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
|
||||
process.stdout.write(frame);
|
||||
}
|
||||
|
||||
respond(id, result) {
|
||||
this.log(`responding to ${id} with result`);
|
||||
if (id === undefined) return;
|
||||
this.write({ jsonrpc: '2.0', id, result });
|
||||
}
|
||||
|
||||
respondError(id, code, message) {
|
||||
this.log(`responding error to ${id}: [${code}] ${message}`);
|
||||
if (id === undefined) return;
|
||||
this.write({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
}
|
||||
|
||||
notify(method, params) {
|
||||
this.log(`notifying ${method}`);
|
||||
this.write({ jsonrpc: '2.0', method, params });
|
||||
}
|
||||
|
||||
async drain() {
|
||||
while (true) {
|
||||
if (this.expectedLength === null) {
|
||||
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) return;
|
||||
const header = this.buffer.slice(0, headerEnd);
|
||||
const match = header.match(/content-length:\s*(\d+)/i);
|
||||
if (!match) {
|
||||
this.buffer = this.buffer.slice(headerEnd + 4);
|
||||
continue;
|
||||
}
|
||||
this.expectedLength = Number.parseInt(match[1], 10);
|
||||
this.buffer = this.buffer.slice(headerEnd + 4);
|
||||
}
|
||||
|
||||
if (this.buffer.length < (this.expectedLength ?? 0)) return;
|
||||
|
||||
const body = this.buffer.slice(0, this.expectedLength ?? 0);
|
||||
this.buffer = this.buffer.slice(this.expectedLength ?? 0);
|
||||
this.expectedLength = null;
|
||||
|
||||
await this.handleMessage(body);
|
||||
}
|
||||
}
|
||||
|
||||
async handleMessage(payload) {
|
||||
this.log(`received payload: ${payload}`);
|
||||
let request;
|
||||
try {
|
||||
request = JSON.parse(payload);
|
||||
} catch {
|
||||
this.respondError(null, -32700, 'Parse error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request || request.jsonrpc !== '2.0' || typeof request.method !== 'string') {
|
||||
this.respondError(request?.id, -32600, 'Invalid Request');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.dispatch(request);
|
||||
} catch (error) {
|
||||
this.log(`Unexpected error: ${error.message}`);
|
||||
this.respondError(request.id, -32000, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatch(request) {
|
||||
switch (request.method) {
|
||||
case 'initialize': {
|
||||
if (this.initialized) {
|
||||
this.respondError(request.id, -32600, 'Already initialized');
|
||||
return;
|
||||
}
|
||||
this.initialized = true;
|
||||
this.respond(request.id, {
|
||||
protocolVersion: '2024-10-07',
|
||||
capabilities: { tools: { list: true, call: true } },
|
||||
service: {
|
||||
name: this.options.name,
|
||||
version: this.options.version,
|
||||
description: this.options.description,
|
||||
},
|
||||
});
|
||||
this.notify('notifications/ready', {});
|
||||
return;
|
||||
}
|
||||
|
||||
case 'tools/list': {
|
||||
this.assertInitialized('tools/list');
|
||||
this.respond(request.id, { tools: this.metadata });
|
||||
return;
|
||||
}
|
||||
|
||||
case 'tools/call': {
|
||||
this.assertInitialized('tools/call');
|
||||
const params = request.params ?? {};
|
||||
const toolName = params.name;
|
||||
if (!toolName || typeof toolName !== 'string') {
|
||||
this.respondError(request.id, -32602, 'Tool name is required');
|
||||
return;
|
||||
}
|
||||
const tool = this.tools.get(toolName);
|
||||
if (!tool) {
|
||||
this.respondError(request.id, -32601, `Unknown tool: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await tool.handler(params.arguments ?? {});
|
||||
this.respond(request.id, result);
|
||||
} catch (error) {
|
||||
this.respondError(request.id, -32001, error.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case 'ping': {
|
||||
this.respond(request.id, 'pong');
|
||||
return;
|
||||
}
|
||||
|
||||
case 'shutdown': {
|
||||
this.respond(request.id, null);
|
||||
process.nextTick(() => process.exit(0));
|
||||
return;
|
||||
}
|
||||
|
||||
default: {
|
||||
this.respondError(request.id, -32601, `Method not found: ${request.method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertInitialized(method) {
|
||||
if (!this.initialized) {
|
||||
throw new Error(`Received ${method} before initialize`);
|
||||
}
|
||||
}
|
||||
|
||||
log(message) {
|
||||
process.stderr.write(`[${this.options.name}] ${message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const jsonResult = (data) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'application/json',
|
||||
data,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const ensureNumber = (value, field) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
throw new Error(`${field} must be a number`);
|
||||
};
|
||||
|
||||
const optionalNumber = (value, field) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return ensureNumber(value, field);
|
||||
};
|
||||
|
||||
const optionalNullableNumber = (value, field) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized || normalized === 'null') return null;
|
||||
}
|
||||
return ensureNumber(value, field);
|
||||
};
|
||||
|
||||
const ensureString = (value, field) => {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new Error(`${field} cannot be empty`);
|
||||
return trimmed;
|
||||
}
|
||||
if (value === undefined || value === null) throw new Error(`${field} is required`);
|
||||
return ensureString(String(value), field);
|
||||
};
|
||||
|
||||
const optionalString = (value) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const optionalNullableString = (value) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
const normalized = String(value).trim();
|
||||
if (normalized.toLowerCase() === 'null') return null;
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const optionalBoolean = (value, field) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['true', '1', 'yes', 'y'].includes(normalized)) return true;
|
||||
if (['false', '0', 'no', 'n'].includes(normalized)) return false;
|
||||
}
|
||||
throw new Error(`${field} must be boolean`);
|
||||
};
|
||||
|
||||
const parseStringArray = (value) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
let items = [];
|
||||
if (Array.isArray(value)) {
|
||||
items = value.map((item) => String(item).trim()).filter(Boolean);
|
||||
} else if (typeof value === 'string') {
|
||||
items = value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||
} else {
|
||||
items = [String(value).trim()].filter(Boolean);
|
||||
}
|
||||
return items.length ? items : undefined;
|
||||
};
|
||||
|
||||
const buildTransactionCreatePayload = (args, options = {}) => {
|
||||
const payload = {
|
||||
type: optionalString(args?.type) ?? options.defaultType ?? ensureString(args?.type, 'type'),
|
||||
amount: ensureNumber(args?.amount, 'amount'),
|
||||
currency: optionalString(args?.currency) ?? 'CNY',
|
||||
transactionDate: ensureString(args?.transactionDate, 'transactionDate'),
|
||||
};
|
||||
|
||||
const categoryId = optionalNullableNumber(args?.categoryId, 'categoryId');
|
||||
if (categoryId !== undefined) payload.categoryId = categoryId;
|
||||
|
||||
const accountId = optionalNullableNumber(args?.accountId, 'accountId');
|
||||
if (accountId !== undefined) payload.accountId = accountId;
|
||||
|
||||
const description = optionalString(args?.description);
|
||||
if (description !== undefined) payload.description = description;
|
||||
|
||||
const project = optionalNullableString(args?.project);
|
||||
if (project !== undefined) payload.project = project;
|
||||
|
||||
const memo = optionalNullableString(args?.memo);
|
||||
if (memo !== undefined) payload.memo = memo;
|
||||
|
||||
const status = optionalString(args?.status);
|
||||
if (status !== undefined) payload.status = status;
|
||||
|
||||
const reimbursementBatch = optionalNullableString(args?.reimbursementBatch);
|
||||
if (reimbursementBatch !== undefined) payload.reimbursementBatch = reimbursementBatch;
|
||||
|
||||
const reviewNotes = optionalNullableString(args?.reviewNotes);
|
||||
if (reviewNotes !== undefined) payload.reviewNotes = reviewNotes;
|
||||
|
||||
const submittedBy = optionalNullableString(args?.submittedBy);
|
||||
if (submittedBy !== undefined) payload.submittedBy = submittedBy;
|
||||
|
||||
const approvedBy = optionalNullableString(args?.approvedBy);
|
||||
if (approvedBy !== undefined) payload.approvedBy = approvedBy;
|
||||
|
||||
const approvedAt = optionalNullableString(args?.approvedAt);
|
||||
if (approvedAt !== undefined) payload.approvedAt = approvedAt;
|
||||
|
||||
const statusUpdatedAt = optionalNullableString(args?.statusUpdatedAt);
|
||||
if (statusUpdatedAt !== undefined) payload.statusUpdatedAt = statusUpdatedAt;
|
||||
|
||||
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
|
||||
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const buildTransactionUpdatePayload = (args) => {
|
||||
const payload = {};
|
||||
|
||||
if (args?.type !== undefined) payload.type = ensureString(args.type, 'type');
|
||||
if (args?.amount !== undefined) payload.amount = ensureNumber(args.amount, 'amount');
|
||||
if (args?.currency !== undefined) payload.currency = ensureString(args.currency, 'currency');
|
||||
if (args?.transactionDate !== undefined) payload.transactionDate = ensureString(args.transactionDate, 'transactionDate');
|
||||
if (args?.categoryId !== undefined) payload.categoryId = optionalNullableNumber(args.categoryId, 'categoryId');
|
||||
if (args?.accountId !== undefined) payload.accountId = optionalNullableNumber(args.accountId, 'accountId');
|
||||
if (args?.description !== undefined) payload.description = args.description === null ? '' : String(args.description);
|
||||
if (args?.project !== undefined) payload.project = optionalNullableString(args.project) ?? null;
|
||||
if (args?.memo !== undefined) payload.memo = optionalNullableString(args.memo) ?? null;
|
||||
if (args?.status !== undefined) payload.status = ensureString(args.status, 'status');
|
||||
if (args?.statusUpdatedAt !== undefined) payload.statusUpdatedAt = ensureString(args.statusUpdatedAt, 'statusUpdatedAt');
|
||||
if (args?.reimbursementBatch !== undefined) payload.reimbursementBatch = optionalNullableString(args.reimbursementBatch) ?? null;
|
||||
if (args?.reviewNotes !== undefined) payload.reviewNotes = optionalNullableString(args.reviewNotes) ?? null;
|
||||
if (args?.submittedBy !== undefined) payload.submittedBy = optionalNullableString(args.submittedBy) ?? null;
|
||||
if (args?.approvedBy !== undefined) payload.approvedBy = optionalNullableString(args.approvedBy) ?? null;
|
||||
if (args?.approvedAt !== undefined) payload.approvedAt = optionalNullableString(args.approvedAt) ?? null;
|
||||
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
|
||||
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const createFinanceTools = (client) => {
|
||||
const tools = [];
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_accounts',
|
||||
description: '列出账户,可选货币过滤',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
currency: { type: 'string', description: 'ISO 4217 货币代码' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const currency = optionalString(args?.currency);
|
||||
return jsonResult(await client.listAccounts(currency ? { currency } : {}));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_budgets',
|
||||
description: '查询预算列表',
|
||||
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
||||
handler: async () => jsonResult(await client.listBudgets()),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_create_budget',
|
||||
description: '创建预算',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['category', 'categoryId', 'limit', 'currency', 'period'],
|
||||
properties: {
|
||||
category: { type: 'string' },
|
||||
categoryId: { type: 'number' },
|
||||
emoji: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
spent: { type: 'number' },
|
||||
remaining: { type: 'number' },
|
||||
percentage: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
period: { type: 'string' },
|
||||
alertThreshold: { type: 'number' },
|
||||
description: { type: 'string' },
|
||||
autoRenew: { type: 'boolean' },
|
||||
overspendAlert: { type: 'boolean' },
|
||||
dailyReminder: { type: 'boolean' },
|
||||
monthlyTrend: { type: 'number' },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const payload = {
|
||||
category: ensureString(args?.category, 'category'),
|
||||
categoryId: ensureNumber(args?.categoryId, 'categoryId'),
|
||||
emoji: optionalString(args?.emoji),
|
||||
limit: ensureNumber(args?.limit, 'limit'),
|
||||
spent: optionalNumber(args?.spent, 'spent'),
|
||||
remaining: optionalNumber(args?.remaining, 'remaining'),
|
||||
percentage: optionalNumber(args?.percentage, 'percentage'),
|
||||
currency: ensureString(args?.currency, 'currency'),
|
||||
period: ensureString(args?.period, 'period'),
|
||||
alertThreshold: optionalNumber(args?.alertThreshold, 'alertThreshold'),
|
||||
description: optionalString(args?.description),
|
||||
autoRenew: optionalBoolean(args?.autoRenew, 'autoRenew'),
|
||||
overspendAlert: optionalBoolean(args?.overspendAlert, 'overspendAlert'),
|
||||
dailyReminder: optionalBoolean(args?.dailyReminder, 'dailyReminder'),
|
||||
monthlyTrend: optionalNumber(args?.monthlyTrend, 'monthlyTrend'),
|
||||
isDeleted: optionalBoolean(args?.isDeleted, 'isDeleted'),
|
||||
};
|
||||
return jsonResult(await client.createBudget(payload));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_update_budget',
|
||||
description: '更新预算',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
category: { type: 'string' },
|
||||
categoryId: { type: 'number' },
|
||||
emoji: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
spent: { type: 'number' },
|
||||
remaining: { type: 'number' },
|
||||
percentage: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
period: { type: 'string' },
|
||||
alertThreshold: { type: 'number' },
|
||||
description: { type: 'string' },
|
||||
autoRenew: { type: 'boolean' },
|
||||
overspendAlert: { type: 'boolean' },
|
||||
dailyReminder: { type: 'boolean' },
|
||||
monthlyTrend: { type: 'number' },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const id = ensureNumber(args?.id, 'id');
|
||||
const payload = {};
|
||||
if (args?.category !== undefined) payload.category = ensureString(args.category, 'category');
|
||||
if (args?.categoryId !== undefined) payload.categoryId = ensureNumber(args.categoryId, 'categoryId');
|
||||
if (args?.emoji !== undefined) payload.emoji = optionalString(args.emoji);
|
||||
if (args?.limit !== undefined) payload.limit = ensureNumber(args.limit, 'limit');
|
||||
if (args?.spent !== undefined) payload.spent = ensureNumber(args.spent, 'spent');
|
||||
if (args?.remaining !== undefined) payload.remaining = ensureNumber(args.remaining, 'remaining');
|
||||
if (args?.percentage !== undefined) payload.percentage = ensureNumber(args.percentage, 'percentage');
|
||||
if (args?.currency !== undefined) payload.currency = ensureString(args.currency, 'currency');
|
||||
if (args?.period !== undefined) payload.period = ensureString(args.period, 'period');
|
||||
if (args?.alertThreshold !== undefined) payload.alertThreshold = ensureNumber(args.alertThreshold, 'alertThreshold');
|
||||
if (args?.description !== undefined) payload.description = optionalString(args.description);
|
||||
const autoRenew = optionalBoolean(args?.autoRenew, 'autoRenew');
|
||||
if (autoRenew !== undefined) payload.autoRenew = autoRenew;
|
||||
const overspendAlert = optionalBoolean(args?.overspendAlert, 'overspendAlert');
|
||||
if (overspendAlert !== undefined) payload.overspendAlert = overspendAlert;
|
||||
const dailyReminder = optionalBoolean(args?.dailyReminder, 'dailyReminder');
|
||||
if (dailyReminder !== undefined) payload.dailyReminder = dailyReminder;
|
||||
if (args?.monthlyTrend !== undefined) payload.monthlyTrend = ensureNumber(args.monthlyTrend, 'monthlyTrend');
|
||||
const isDeleted = optionalBoolean(args?.isDeleted, 'isDeleted');
|
||||
if (isDeleted !== undefined) payload.isDeleted = isDeleted;
|
||||
return jsonResult(await client.updateBudget(id, payload));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_delete_budget',
|
||||
description: '删除预算(软删)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'number' } },
|
||||
},
|
||||
handler: async (args) => jsonResult(await client.deleteBudget(ensureNumber(args?.id, 'id'))),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_categories',
|
||||
description: '查询分类,可按类型过滤',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
type: { type: 'string', description: 'expense / income' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const type = optionalString(args?.type);
|
||||
return jsonResult(await client.listCategories(type ? { type } : {}));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_create_category',
|
||||
description: '创建分类',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['name', 'type'],
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
icon: { type: 'string' },
|
||||
color: { type: 'string' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => jsonResult(
|
||||
await client.createCategory({
|
||||
name: ensureString(args?.name, 'name'),
|
||||
type: ensureString(args?.type, 'type'),
|
||||
icon: optionalString(args?.icon),
|
||||
color: optionalString(args?.color),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_update_category',
|
||||
description: '更新分类',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
icon: { type: 'string' },
|
||||
color: { type: 'string' },
|
||||
isActive: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const id = ensureNumber(args?.id, 'id');
|
||||
const payload = {};
|
||||
if (args?.name !== undefined) payload.name = ensureString(args.name, 'name');
|
||||
if (args?.icon !== undefined) payload.icon = optionalString(args.icon);
|
||||
if (args?.color !== undefined) payload.color = optionalString(args.color);
|
||||
const isActive = optionalBoolean(args?.isActive, 'isActive');
|
||||
if (isActive !== undefined) payload.isActive = isActive;
|
||||
return jsonResult(await client.updateCategory(id, payload));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_delete_category',
|
||||
description: '删除分类(软删)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'number' } },
|
||||
},
|
||||
handler: async (args) => jsonResult(await client.deleteCategory(ensureNumber(args?.id, 'id'))),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_currencies',
|
||||
description: '列出可用货币',
|
||||
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
||||
handler: async () => jsonResult(await client.listCurrencies()),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_exchange_rates',
|
||||
description: '查询汇率',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
fromCurrency: { type: 'string' },
|
||||
toCurrency: { type: 'string' },
|
||||
date: { type: 'string' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const params = {};
|
||||
if (args?.fromCurrency !== undefined) params.fromCurrency = ensureString(args.fromCurrency, 'fromCurrency');
|
||||
if (args?.toCurrency !== undefined) params.toCurrency = ensureString(args.toCurrency, 'toCurrency');
|
||||
if (args?.date !== undefined) params.date = ensureString(args.date, 'date');
|
||||
return jsonResult(await client.listExchangeRates(params));
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_transactions',
|
||||
description: '查询交易列表',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
statuses: { type: ['array', 'string'], items: { type: 'string' } },
|
||||
includeDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const type = optionalString(args?.type);
|
||||
const statuses = parseStringArray(args?.statuses);
|
||||
const includeDeleted = optionalBoolean(args?.includeDeleted, 'includeDeleted');
|
||||
return jsonResult(
|
||||
await client.listTransactions({
|
||||
...(type ? { type } : {}),
|
||||
...(statuses ? { statuses } : {}),
|
||||
...(includeDeleted !== undefined ? { includeDeleted } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_create_transaction',
|
||||
description: '创建交易',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['type', 'amount', 'transactionDate'],
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
categoryId: { type: ['number', 'null'] },
|
||||
accountId: { type: ['number', 'null'] },
|
||||
transactionDate: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
project: { type: ['string', 'null'] },
|
||||
memo: { type: ['string', 'null'] },
|
||||
status: { type: 'string' },
|
||||
reimbursementBatch: { type: ['string', 'null'] },
|
||||
reviewNotes: { type: ['string', 'null'] },
|
||||
submittedBy: { type: ['string', 'null'] },
|
||||
approvedBy: { type: ['string', 'null'] },
|
||||
approvedAt: { type: ['string', 'null'] },
|
||||
statusUpdatedAt: { type: ['string', 'null'] },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => jsonResult(await client.createTransaction(buildTransactionCreatePayload(args))),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_update_transaction',
|
||||
description: '更新交易',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
type: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
categoryId: { type: ['number', 'null'] },
|
||||
accountId: { type: ['number', 'null'] },
|
||||
transactionDate: { type: 'string' },
|
||||
description: { type: ['string', 'null'] },
|
||||
project: { type: ['string', 'null'] },
|
||||
memo: { type: ['string', 'null'] },
|
||||
status: { type: 'string' },
|
||||
statusUpdatedAt: { type: 'string' },
|
||||
reimbursementBatch: { type: ['string', 'null'] },
|
||||
reviewNotes: { type: ['string', 'null'] },
|
||||
submittedBy: { type: ['string', 'null'] },
|
||||
approvedBy: { type: ['string', 'null'] },
|
||||
approvedAt: { type: ['string', 'null'] },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => jsonResult(
|
||||
await client.updateTransaction(ensureNumber(args?.id, 'id'), buildTransactionUpdatePayload(args)),
|
||||
),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_delete_transaction',
|
||||
description: '删除交易(软删)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'number' } },
|
||||
},
|
||||
handler: async (args) => jsonResult(await client.deleteTransaction(ensureNumber(args?.id, 'id'))),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_reimbursements',
|
||||
description: '查询报销单',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
statuses: { type: ['array', 'string'], items: { type: 'string' } },
|
||||
includeDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const type = optionalString(args?.type);
|
||||
const statuses = parseStringArray(args?.statuses);
|
||||
const includeDeleted = optionalBoolean(args?.includeDeleted, 'includeDeleted');
|
||||
return jsonResult(
|
||||
await client.listReimbursements({
|
||||
...(type ? { type } : {}),
|
||||
...(statuses ? { statuses } : {}),
|
||||
...(includeDeleted !== undefined ? { includeDeleted } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_create_reimbursement',
|
||||
description: '创建报销单',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['amount', 'transactionDate'],
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
categoryId: { type: ['number', 'null'] },
|
||||
accountId: { type: ['number', 'null'] },
|
||||
transactionDate: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
project: { type: ['string', 'null'] },
|
||||
memo: { type: ['string', 'null'] },
|
||||
status: { type: 'string' },
|
||||
reimbursementBatch: { type: ['string', 'null'] },
|
||||
reviewNotes: { type: ['string', 'null'] },
|
||||
submittedBy: { type: ['string', 'null'] },
|
||||
approvedBy: { type: ['string', 'null'] },
|
||||
approvedAt: { type: ['string', 'null'] },
|
||||
statusUpdatedAt: { type: ['string', 'null'] },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => jsonResult(
|
||||
await client.createReimbursement(buildTransactionCreatePayload(args, { defaultType: 'expense' })),
|
||||
),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_update_reimbursement',
|
||||
description: '更新报销单',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
type: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
currency: { type: 'string' },
|
||||
categoryId: { type: ['number', 'null'] },
|
||||
accountId: { type: ['number', 'null'] },
|
||||
transactionDate: { type: 'string' },
|
||||
description: { type: ['string', 'null'] },
|
||||
project: { type: ['string', 'null'] },
|
||||
memo: { type: ['string', 'null'] },
|
||||
status: { type: 'string' },
|
||||
statusUpdatedAt: { type: 'string' },
|
||||
reimbursementBatch: { type: ['string', 'null'] },
|
||||
reviewNotes: { type: ['string', 'null'] },
|
||||
submittedBy: { type: ['string', 'null'] },
|
||||
approvedBy: { type: ['string', 'null'] },
|
||||
approvedAt: { type: ['string', 'null'] },
|
||||
isDeleted: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
handler: async (args) => jsonResult(
|
||||
await client.updateReimbursement(
|
||||
ensureNumber(args?.id, 'id'),
|
||||
buildTransactionUpdatePayload(args),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_list_media',
|
||||
description: '查询媒体消息',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
limit: { type: 'number' },
|
||||
fileTypes: { type: ['array', 'string'], items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const limit = optionalNumber(args?.limit, 'limit');
|
||||
const fileTypes = parseStringArray(args?.fileTypes);
|
||||
return jsonResult(
|
||||
await client.listMedia({
|
||||
...(limit !== undefined ? { limit } : {}),
|
||||
...(fileTypes ? { fileTypes } : {}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_get_media',
|
||||
description: '根据 ID 获取媒体详情',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'number' } },
|
||||
},
|
||||
handler: async (args) => jsonResult(await client.getMediaById(ensureNumber(args?.id, 'id'))),
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'finance_download_media',
|
||||
description: '下载媒体文件并返回 Base64',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
includeMetadata: { type: 'boolean', default: true },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fileName: { type: ['string', 'null'] },
|
||||
mimeType: { type: 'string' },
|
||||
size: { type: 'number' },
|
||||
base64: { type: 'string' },
|
||||
metadata: { type: ['object', 'null'] },
|
||||
},
|
||||
},
|
||||
handler: async (args) => {
|
||||
const id = ensureNumber(args?.id, 'id');
|
||||
const includeMetadata = optionalBoolean(args?.includeMetadata, 'includeMetadata');
|
||||
const file = await client.downloadMedia(id);
|
||||
const metadata = includeMetadata === false ? null : await client.getMediaById(id);
|
||||
return jsonResult({ ...file, metadata });
|
||||
},
|
||||
});
|
||||
|
||||
return tools;
|
||||
};
|
||||
|
||||
const createServer = () => {
|
||||
const baseUrl =
|
||||
process.env.FINANCE_API_BASE_URL ?? 'http://172.16.74.149:5666';
|
||||
const apiKey = process.env.FINANCE_API_KEY;
|
||||
const timeoutEnv = process.env.FINANCE_API_TIMEOUT;
|
||||
const timeout = timeoutEnv ? Number.parseInt(timeoutEnv, 10) : undefined;
|
||||
const basicUsername =
|
||||
process.env.FINANCE_BASIC_USERNAME ??
|
||||
process.env.FINANCE_BASIC_USER ??
|
||||
process.env.FINANCE_USERNAME;
|
||||
const basicPassword =
|
||||
process.env.FINANCE_BASIC_PASSWORD ??
|
||||
process.env.FINANCE_PASSWORD;
|
||||
const basicAuth =
|
||||
basicUsername && basicPassword ? { username: basicUsername, password: basicPassword } : undefined;
|
||||
|
||||
const client = new FinanceClient({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
basicAuth,
|
||||
timeoutMs: Number.isFinite(timeout ?? NaN) ? timeout : undefined,
|
||||
});
|
||||
|
||||
return new McpServer({
|
||||
name: 'finwise-finance',
|
||||
version: '0.1.0',
|
||||
description: 'Finwise Pro 财务接口 MCP 服务',
|
||||
tools: createFinanceTools(client),
|
||||
});
|
||||
};
|
||||
|
||||
createServer().start();
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user