first commot
This commit is contained in:
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
**/.dockerignore
|
||||||
|
**/.env
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.project
|
||||||
|
**/.settings
|
||||||
|
**/.toolstarget
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/*.*proj.user
|
||||||
|
**/*.dbmdl
|
||||||
|
**/*.jfm
|
||||||
|
**/azds.yaml
|
||||||
|
**/bin
|
||||||
|
**/charts
|
||||||
|
**/docker-compose*
|
||||||
|
**/Dockerfile*
|
||||||
|
**/node_modules
|
||||||
|
**/npm-debug.log
|
||||||
|
**/obj
|
||||||
|
**/secrets.dev.yaml
|
||||||
|
**/values.dev.yaml
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
44
.gitea/workflows/push.yml
Normal file
44
.gitea/workflows/push.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: Docker Build & Deploy
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
COMPOSE_PROJECT_NAME: emailbill
|
||||||
|
IMAGE_NAME: emailbill-app
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# ✅ 使用 Gitea 兼容的代码检出方式
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
gitea-server: http://192.168.31.14:14200
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
ref: ${{ gitea.ref }} # 必须传递 Gitea 的 ref 参数
|
||||||
|
|
||||||
|
- name: Cleanup old containers
|
||||||
|
run: |
|
||||||
|
docker compose -p $COMPOSE_PROJECT_NAME down || true
|
||||||
|
docker rmi $IMAGE_NAME || true
|
||||||
|
|
||||||
|
- name: Build new image
|
||||||
|
run: docker build -t $IMAGE_NAME .
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: https://gitea.com/actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Start containers
|
||||||
|
run: |
|
||||||
|
docker compose -p $COMPOSE_PROJECT_NAME down
|
||||||
|
docker compose -p $COMPOSE_PROJECT_NAME up -d --build
|
||||||
402
.gitignore
vendored
Normal file
402
.gitignore
vendored
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET Core
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||||
|
*.vbp
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
# tools/**
|
||||||
|
# !tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
.localhistory/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# JetBrains Rider
|
||||||
|
*.sln.iml
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.idea/
|
||||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/cache
|
||||||
84
.serena/project.yml
Normal file
84
.serena/project.yml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# list of languages for which language servers are started; choose from:
|
||||||
|
# al bash clojure cpp csharp csharp_omnisharp
|
||||||
|
# dart elixir elm erlang fortran go
|
||||||
|
# haskell java julia kotlin lua markdown
|
||||||
|
# nix perl php python python_jedi r
|
||||||
|
# rego ruby ruby_solargraph rust scala swift
|
||||||
|
# terraform typescript typescript_vts yaml zig
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# Special requirements:
|
||||||
|
# - csharp: Requires the presence of a .sln file in the project folder.
|
||||||
|
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||||
|
# The first language is the default language and the respective language server will be used as a fallback.
|
||||||
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
|
languages:
|
||||||
|
- csharp
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# whether to use the project's gitignore file to ignore files
|
||||||
|
# Added on 2025-04-07
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# list of additional paths to ignore
|
||||||
|
# same syntax as gitignore, so you can use * and **
|
||||||
|
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||||
|
# Added (renamed) on 2025-04-07
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
project_name: "EmailBill"
|
||||||
|
included_optional_tools: []
|
||||||
7
Common/Common.csproj
Normal file
7
Common/Common.csproj
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
79
Common/ServiceExtension.cs
Normal file
79
Common/ServiceExtension.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务依赖注入扩展
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceExtension
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 自动扫描并注册服务和仓储
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
// 扫描程序集
|
||||||
|
var serviceAssembly = Assembly.Load("Service");
|
||||||
|
var repositoryAssembly = Assembly.Load("Repository");
|
||||||
|
|
||||||
|
// 注册所有服务实现
|
||||||
|
RegisterServices(services, serviceAssembly);
|
||||||
|
|
||||||
|
// 注册所有仓储实现
|
||||||
|
RegisterRepositories(services, repositoryAssembly);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterServices(IServiceCollection services, Assembly assembly)
|
||||||
|
{
|
||||||
|
var types = assembly.GetTypes()
|
||||||
|
.Where(t => t.IsClass && !t.IsAbstract);
|
||||||
|
|
||||||
|
foreach (var type in types)
|
||||||
|
{
|
||||||
|
var interfaces = type.GetInterfaces()
|
||||||
|
.Where(i => i.Name.StartsWith("I") && i.Namespace != null && i.Namespace.StartsWith("Service"));
|
||||||
|
|
||||||
|
foreach (var @interface in interfaces)
|
||||||
|
{
|
||||||
|
// EmailBackgroundService 必须是 Singleton(后台服务),其他服务可用 Transient
|
||||||
|
if (type.Name == "EmailBackgroundService")
|
||||||
|
{
|
||||||
|
services.AddSingleton(@interface, type);
|
||||||
|
}
|
||||||
|
else if (type.Name == "EmailFetchService")
|
||||||
|
{
|
||||||
|
// EmailFetchService 用 Transient,避免连接冲突
|
||||||
|
services.AddTransient(@interface, type);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddSingleton(@interface, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterRepositories(IServiceCollection services, Assembly assembly)
|
||||||
|
{
|
||||||
|
var types = assembly.GetTypes()
|
||||||
|
.Where(t => t.IsClass && !t.IsAbstract);
|
||||||
|
|
||||||
|
foreach (var type in types)
|
||||||
|
{
|
||||||
|
var interfaces = type.GetInterfaces()
|
||||||
|
.Where(i => i.Name.StartsWith("I")
|
||||||
|
&& i.Namespace == "Repository"
|
||||||
|
&& !i.IsGenericType); // 排除泛型接口如 IBaseRepository<T>
|
||||||
|
|
||||||
|
foreach (var @interface in interfaces)
|
||||||
|
{
|
||||||
|
services.AddSingleton(@interface, type);
|
||||||
|
Console.WriteLine($"注册 Repository: {@interface.Name} -> {type.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
8
Directory.Build.props
Normal file
8
Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
28
Directory.Packages.props
Normal file
28
Directory.Packages.props
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<Project>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Email & MIME Libraries -->
|
||||||
|
<PackageVersion Include="FreeSql" Version="3.5.304" />
|
||||||
|
<PackageVersion Include="MailKit" Version="4.14.1" />
|
||||||
|
<PackageVersion Include="MimeKit" Version="4.14.0" />
|
||||||
|
<!-- Dependency Injection & Configuration -->
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||||
|
<!-- Logging -->
|
||||||
|
<PackageVersion Include="Serilog" Version="4.3.0" />
|
||||||
|
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<!-- Web & API -->
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.11.9" />
|
||||||
|
<!-- Database -->
|
||||||
|
<PackageVersion Include="FreeSql.Provider.Sqlite" Version="3.5.304" />
|
||||||
|
<PackageVersion Include="Yitter.IdGenerator" Version="1.0.14" />
|
||||||
|
<!-- File Processing -->
|
||||||
|
<PackageVersion Include="CsvHelper" Version="33.0.1" />
|
||||||
|
<PackageVersion Include="EPPlus" Version="7.5.2" />
|
||||||
|
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
66
Dockerfile
Normal file
66
Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 多阶段构建 Dockerfile
|
||||||
|
# 第一阶段:构建前端
|
||||||
|
FROM node:20-alpine AS frontend-build
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# 复制前端项目文件
|
||||||
|
COPY Web/package.json Web/pnpm-lock.yaml ./
|
||||||
|
RUN npm install -g pnpm && pnpm install
|
||||||
|
|
||||||
|
# 复制前端源代码并构建
|
||||||
|
COPY Web/ ./
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# 第二阶段:构建后端
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend-build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制解决方案文件和项目文件
|
||||||
|
COPY *.sln ./
|
||||||
|
COPY Directory.Build.props ./
|
||||||
|
COPY Directory.Packages.props ./
|
||||||
|
COPY nuget.config ./
|
||||||
|
COPY Common/*.csproj ./Common/
|
||||||
|
COPY Entity/*.csproj ./Entity/
|
||||||
|
COPY Repository/*.csproj ./Repository/
|
||||||
|
COPY Service/*.csproj ./Service/
|
||||||
|
COPY WebApi/*.csproj ./WebApi/
|
||||||
|
|
||||||
|
# 还原依赖
|
||||||
|
RUN dotnet restore
|
||||||
|
|
||||||
|
# 复制所有源代码
|
||||||
|
COPY Common/ ./Common/
|
||||||
|
COPY Entity/ ./Entity/
|
||||||
|
COPY Repository/ ./Repository/
|
||||||
|
COPY Service/ ./Service/
|
||||||
|
COPY WebApi/ ./WebApi/
|
||||||
|
|
||||||
|
# 构建并发布
|
||||||
|
RUN dotnet publish WebApi/WebApi.csproj -c Release -o /app/publish
|
||||||
|
|
||||||
|
# 将前端构建产物复制到后端的 wwwroot 目录
|
||||||
|
COPY --from=frontend-build /app/frontend/dist /app/publish/wwwroot
|
||||||
|
|
||||||
|
# 第三阶段:运行时镜像
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制发布的应用
|
||||||
|
COPY --from=backend-build /app/publish ./
|
||||||
|
|
||||||
|
# 创建数据库目录
|
||||||
|
RUN mkdir -p /app/database
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
ENTRYPOINT ["dotnet", "WebApi.dll"]
|
||||||
90
EmailBill.sln
Normal file
90
EmailBill.sln
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi", "WebApi\WebApi.csproj", "{4818F6EB-A555-41AC-AABF-2E09CE5149BC}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Service", "Service\Service.csproj", "{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Repository", "Repository\Repository.csproj", "{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csproj", "{F8C621DA-478B-481A-B6B9-1C16D595C41D}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entity", "Entity\Entity.csproj", "{B1BCD944-C4F5-406E-AE66-864E4BA21522}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{4818F6EB-A555-41AC-AABF-2E09CE5149BC}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{8090BAB0-B9B4-483A-BF2C-4BF5D2D91D93}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A76B51D5-8EC1-4166-B897-E6C125CA4FFB}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{F8C621DA-478B-481A-B6B9-1C16D595C41D}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
2
EmailBill.sln.DotSettings
Normal file
2
EmailBill.sln.DotSettings
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=fsql/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||||
25
Entity/BaseEntity.cs
Normal file
25
Entity/BaseEntity.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using Yitter.IdGenerator;
|
||||||
|
|
||||||
|
namespace Entity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实体基类
|
||||||
|
/// </summary>
|
||||||
|
public abstract class BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 主键ID (雪花算法有序ID)
|
||||||
|
/// </summary>
|
||||||
|
[Column(IsPrimary = true)]
|
||||||
|
public long Id { get; set; } = YitIdHelper.NextId();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建时间
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreateTime { get; set; } = DateTime.Now;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新时间
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? UpdateTime { get; set; }
|
||||||
|
}
|
||||||
32
Entity/EmailMessage.cs
Normal file
32
Entity/EmailMessage.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Entity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮件消息实体
|
||||||
|
/// </summary>
|
||||||
|
public class EmailMessage : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 邮件主题
|
||||||
|
/// </summary>
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮件发送者
|
||||||
|
/// </summary>
|
||||||
|
public string From { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮件正文
|
||||||
|
/// </summary>
|
||||||
|
public string Body { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮件HTML内容
|
||||||
|
/// </summary>
|
||||||
|
public string HtmlBody { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮件接收时间
|
||||||
|
/// </summary>
|
||||||
|
public DateTime ReceivedDate { get; set; }
|
||||||
|
}
|
||||||
6
Entity/Entity.csproj
Normal file
6
Entity/Entity.csproj
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FreeSql" />
|
||||||
|
<PackageReference Include="Yitter.IdGenerator" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
1
Entity/GlobalUsings.cs
Normal file
1
Entity/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
global using FreeSql.DataAnnotations;
|
||||||
47
Entity/TransactionCategory.cs
Normal file
47
Entity/TransactionCategory.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
namespace Entity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易分类(层级结构:类型 -> 分类 -> 子分类)
|
||||||
|
/// </summary>
|
||||||
|
public class TransactionCategory : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分类名称
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 父分类ID(0表示顶级分类)
|
||||||
|
/// </summary>
|
||||||
|
public long ParentId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易类型(支出/收入)
|
||||||
|
/// </summary>
|
||||||
|
public TransactionType Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 层级(1=类型级, 2=分类级, 3=子分类级)
|
||||||
|
/// </summary>
|
||||||
|
public int Level { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排序号
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图标(可选)
|
||||||
|
/// </summary>
|
||||||
|
public string? Icon { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 备注
|
||||||
|
/// </summary>
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
}
|
||||||
85
Entity/TransactionRecord.cs
Normal file
85
Entity/TransactionRecord.cs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
namespace Entity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 银行交易记录(由邮件解析生成)
|
||||||
|
/// </summary>
|
||||||
|
public class TransactionRecord : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 卡号或账户标识
|
||||||
|
/// </summary>
|
||||||
|
public string Card { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易原因/摘要
|
||||||
|
/// </summary>
|
||||||
|
public string Reason { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易金额
|
||||||
|
/// </summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退款金额
|
||||||
|
/// </summary>
|
||||||
|
public decimal RefundAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易后余额
|
||||||
|
/// </summary>
|
||||||
|
public decimal Balance { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发生时间(邮件中的交易时间)
|
||||||
|
/// </summary>
|
||||||
|
public DateTime OccurredAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 原始邮件记录ID
|
||||||
|
/// </summary>
|
||||||
|
public long EmailMessageId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易类型
|
||||||
|
/// </summary>
|
||||||
|
public TransactionType Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易分类
|
||||||
|
/// </summary>
|
||||||
|
public string Classify { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易子分类
|
||||||
|
/// </summary>
|
||||||
|
public string SubClassify { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导入编号
|
||||||
|
/// </summary>
|
||||||
|
public string ImportNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导入来源
|
||||||
|
/// </summary>
|
||||||
|
public string ImportFrom { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TransactionType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 支出
|
||||||
|
/// </summary>
|
||||||
|
Expense = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收入
|
||||||
|
/// </summary>
|
||||||
|
Income = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 不计入收支
|
||||||
|
/// </summary>
|
||||||
|
None = 2
|
||||||
|
}
|
||||||
27
Entity/WeatherForecast.cs
Normal file
27
Entity/WeatherForecast.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Entity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 天气预报实体
|
||||||
|
/// </summary>
|
||||||
|
public class WeatherForecast : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 日期
|
||||||
|
/// </summary>
|
||||||
|
public DateOnly Date { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 摄氏温度
|
||||||
|
/// </summary>
|
||||||
|
public int TemperatureC { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 摄氏度转华氏度
|
||||||
|
/// </summary>
|
||||||
|
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 天气摘要
|
||||||
|
/// </summary>
|
||||||
|
public string? Summary { get; set; }
|
||||||
|
}
|
||||||
142
Repository/BaseRepository.cs
Normal file
142
Repository/BaseRepository.cs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
namespace Repository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 仓储基础接口
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">实体类型</typeparam>
|
||||||
|
public interface IBaseRepository<T> where T : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有数据
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<T>> GetAllAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据ID获取单条数据
|
||||||
|
/// </summary>
|
||||||
|
Task<T?> GetByIdAsync(long id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 添加数据
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> AddAsync(T entity);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 添加数据
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> AddRangeAsync(IEnumerable<T> entities);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新数据
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateAsync(T entity);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量更新数据
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateRangeAsync(IEnumerable<T> entities);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除数据
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> DeleteAsync(long id);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 仓储基类实现 - 基于 FreeSql
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">实体类型</typeparam>
|
||||||
|
public abstract class BaseRepository<T>(IFreeSql freeSql) : IBaseRepository<T> where T : BaseEntity
|
||||||
|
{
|
||||||
|
protected readonly IFreeSql FreeSql = freeSql ?? throw new ArgumentNullException(nameof(freeSql));
|
||||||
|
|
||||||
|
public virtual async Task<IEnumerable<T>> GetAllAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<T>().ToListAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<T?> GetByIdAsync(long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// FreeSql 会根据配置自动识别主键
|
||||||
|
return await FreeSql.Select<T>().Where(x => x.Id == id).FirstAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<bool> AddAsync(T entity)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await FreeSql.Insert(entity).ExecuteAffrowsAsync();
|
||||||
|
return result == 1;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> AddRangeAsync(IEnumerable<T> entities)
|
||||||
|
{
|
||||||
|
var result = await FreeSql.Insert(entities).ExecuteAffrowsAsync();
|
||||||
|
return result == entities.Count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<bool> UpdateAsync(T entity)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var affrows = await FreeSql.Update<T>()
|
||||||
|
.SetSource(entity)
|
||||||
|
.ExecuteAffrowsAsync();
|
||||||
|
return affrows > 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Update failed: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<bool> UpdateRangeAsync(IEnumerable<T> entities)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var affrows = await FreeSql.Update<T>()
|
||||||
|
.SetSource(entities)
|
||||||
|
.ExecuteAffrowsAsync();
|
||||||
|
return affrows == entities.Count();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"UpdateRange failed: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<bool> DeleteAsync(long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var affrows = await FreeSql.Delete<T>().Where(x => x.Id == id).ExecuteAffrowsAsync();
|
||||||
|
return affrows > 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
Repository/EmailMessageRepository.cs
Normal file
64
Repository/EmailMessageRepository.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
namespace Repository;
|
||||||
|
|
||||||
|
public interface IEmailMessageRepository : IBaseRepository<EmailMessage>
|
||||||
|
{
|
||||||
|
Task<EmailMessage?> ExistsAsync(
|
||||||
|
string from,
|
||||||
|
string subject,
|
||||||
|
DateTime receivedDate,
|
||||||
|
string body);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分页获取邮件列表(游标分页)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lastReceivedDate">上一页最后一条记录的接收时间</param>
|
||||||
|
/// <param name="lastId">上一页最后一条记录的ID</param>
|
||||||
|
/// <param name="pageSize">每页数量</param>
|
||||||
|
/// <returns>邮件列表、最后接收时间和最后ID</returns>
|
||||||
|
Task<(List<EmailMessage> list, DateTime? lastReceivedDate, long lastId)> GetPagedListAsync(DateTime? lastReceivedDate, long? lastId, int pageSize = 20);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取总数
|
||||||
|
/// </summary>
|
||||||
|
Task<long> GetTotalCountAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EmailMessageRepository(IFreeSql freeSql) : BaseRepository<EmailMessage>(freeSql), IEmailMessageRepository
|
||||||
|
{
|
||||||
|
public async Task<EmailMessage?> ExistsAsync(
|
||||||
|
string from,
|
||||||
|
string subject,
|
||||||
|
DateTime receivedDate,
|
||||||
|
string body)
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<EmailMessage>()
|
||||||
|
.Where(m => m.From == from && m.Subject == subject && m.ReceivedDate == receivedDate && m.Body == body)
|
||||||
|
.FirstAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(List<EmailMessage> list, DateTime? lastReceivedDate, long lastId)> GetPagedListAsync(DateTime? lastReceivedDate, long? lastId, int pageSize = 20)
|
||||||
|
{
|
||||||
|
var query = FreeSql.Select<EmailMessage>();
|
||||||
|
|
||||||
|
// 如果提供了游标,则获取小于游标位置的记录
|
||||||
|
if (lastReceivedDate.HasValue && lastId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.ReceivedDate < lastReceivedDate.Value ||
|
||||||
|
(e.ReceivedDate == lastReceivedDate.Value && e.Id < lastId.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = await query
|
||||||
|
.OrderByDescending(e => e.ReceivedDate)
|
||||||
|
.OrderByDescending(e => e.Id)
|
||||||
|
.Page(1, pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var lastRecord = list.Count > 0 ? list.Last() : null;
|
||||||
|
return (list, lastRecord?.ReceivedDate, lastRecord?.Id ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> GetTotalCountAsync()
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<EmailMessage>().CountAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Repository/GlobalUsings.cs
Normal file
5
Repository/GlobalUsings.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
global using Entity;
|
||||||
|
global using FreeSql;
|
||||||
|
global using System.Linq;
|
||||||
|
|
||||||
12
Repository/Repository.csproj
Normal file
12
Repository/Repository.csproj
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Common\Common.csproj" />
|
||||||
|
<ProjectReference Include="..\Entity\Entity.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FreeSql.Provider.Sqlite" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
174
Repository/TransactionCategoryRepository.cs
Normal file
174
Repository/TransactionCategoryRepository.cs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
namespace Repository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易分类仓储接口
|
||||||
|
/// </summary>
|
||||||
|
public interface ITransactionCategoryRepository : IBaseRepository<TransactionCategory>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 根据类型获取所有顶级分类(Level=2,即类型下的分类)
|
||||||
|
/// </summary>
|
||||||
|
Task<List<TransactionCategory>> GetTopLevelCategoriesByTypeAsync(TransactionType type);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据父分类ID获取子分类
|
||||||
|
/// </summary>
|
||||||
|
Task<List<TransactionCategory>> GetChildCategoriesAsync(long parentId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取完整的分类树(按类型)
|
||||||
|
/// </summary>
|
||||||
|
Task<List<TransactionCategoryTreeDto>> GetCategoryTreeAsync(TransactionType? type = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据名称和父ID查找分类(防止重复)
|
||||||
|
/// </summary>
|
||||||
|
Task<TransactionCategory?> GetByNameAndParentAsync(string name, long parentId, TransactionType type);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查分类是否被使用
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> IsCategoryInUseAsync(long categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交易分类仓储实现
|
||||||
|
/// </summary>
|
||||||
|
public class TransactionCategoryRepository(IFreeSql freeSql) : BaseRepository<TransactionCategory>(freeSql), ITransactionCategoryRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 根据类型获取所有顶级分类(Level=2)
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<TransactionCategory>> GetTopLevelCategoriesByTypeAsync(TransactionType type)
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<TransactionCategory>()
|
||||||
|
.Where(c => c.Type == type && c.Level == 2 && c.IsEnabled)
|
||||||
|
.OrderBy(c => c.SortOrder)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据父分类ID获取子分类
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<TransactionCategory>> GetChildCategoriesAsync(long parentId)
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<TransactionCategory>()
|
||||||
|
.Where(c => c.ParentId == parentId && c.IsEnabled)
|
||||||
|
.OrderBy(c => c.SortOrder)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取完整的分类树
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<TransactionCategoryTreeDto>> GetCategoryTreeAsync(TransactionType? type = null)
|
||||||
|
{
|
||||||
|
var query = FreeSql.Select<TransactionCategory>()
|
||||||
|
.Where(c => c.IsEnabled);
|
||||||
|
|
||||||
|
if (type.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(c => c.Type == type.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var allCategories = await query
|
||||||
|
.OrderBy(c => c.Type)
|
||||||
|
.OrderBy(c => c.SortOrder)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// 构建树形结构(Level 2为根节点,即各个分类)
|
||||||
|
var result = new List<TransactionCategoryTreeDto>();
|
||||||
|
var level2Categories = allCategories.Where(c => c.Level == 2).ToList();
|
||||||
|
|
||||||
|
foreach (var category in level2Categories)
|
||||||
|
{
|
||||||
|
var treeNode = new TransactionCategoryTreeDto
|
||||||
|
{
|
||||||
|
Id = category.Id,
|
||||||
|
Name = category.Name,
|
||||||
|
Type = category.Type,
|
||||||
|
Level = category.Level,
|
||||||
|
Icon = category.Icon,
|
||||||
|
Children = BuildChildrenTree(category.Id, allCategories)
|
||||||
|
};
|
||||||
|
result.Add(treeNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 递归构建子分类树
|
||||||
|
/// </summary>
|
||||||
|
private List<TransactionCategoryTreeDto> BuildChildrenTree(long parentId, List<TransactionCategory> allCategories)
|
||||||
|
{
|
||||||
|
var children = allCategories.Where(c => c.ParentId == parentId).ToList();
|
||||||
|
var result = new List<TransactionCategoryTreeDto>();
|
||||||
|
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
var treeNode = new TransactionCategoryTreeDto
|
||||||
|
{
|
||||||
|
Id = child.Id,
|
||||||
|
Name = child.Name,
|
||||||
|
Type = child.Type,
|
||||||
|
Level = child.Level,
|
||||||
|
Icon = child.Icon,
|
||||||
|
Children = BuildChildrenTree(child.Id, allCategories)
|
||||||
|
};
|
||||||
|
result.Add(treeNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据名称和父ID查找分类
|
||||||
|
/// </summary>
|
||||||
|
public async Task<TransactionCategory?> GetByNameAndParentAsync(string name, long parentId, TransactionType type)
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<TransactionCategory>()
|
||||||
|
.Where(c => c.Name == name && c.ParentId == parentId && c.Type == type)
|
||||||
|
.FirstAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查分类是否被使用
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> IsCategoryInUseAsync(long categoryId)
|
||||||
|
{
|
||||||
|
// 检查是否有交易记录使用此分类
|
||||||
|
var category = await GetByIdAsync(categoryId);
|
||||||
|
if (category == null) return false;
|
||||||
|
|
||||||
|
// 根据层级检查不同的字段
|
||||||
|
var count = category.Level switch
|
||||||
|
{
|
||||||
|
2 => await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(r => r.Classify == category.Name && r.Type == category.Type)
|
||||||
|
.CountAsync(),
|
||||||
|
3 => await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(r => r.SubClassify == category.Name && r.Type == category.Type)
|
||||||
|
.CountAsync(),
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分类树DTO
|
||||||
|
/// </summary>
|
||||||
|
public class TransactionCategoryTreeDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public TransactionType Type { get; set; }
|
||||||
|
public int Level { get; set; }
|
||||||
|
public string? Icon { get; set; }
|
||||||
|
public List<TransactionCategoryTreeDto> Children { get; set; } = new();
|
||||||
|
}
|
||||||
183
Repository/TransactionRecordRepository.cs
Normal file
183
Repository/TransactionRecordRepository.cs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
namespace Repository;
|
||||||
|
|
||||||
|
public interface ITransactionRecordRepository : IBaseRepository<TransactionRecord>
|
||||||
|
{
|
||||||
|
Task<TransactionRecord?> ExistsByEmailMessageIdAsync(long emailMessageId, DateTime occurredAt);
|
||||||
|
|
||||||
|
Task<TransactionRecord?> ExistsByImportNoAsync(string importNo, string importFrom);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分页获取交易记录列表(游标分页)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="lastOccurredAt">上一页最后一条记录的发生时间</param>
|
||||||
|
/// <param name="lastId">上一页最后一条记录的ID</param>
|
||||||
|
/// <param name="pageSize">每页数量</param>
|
||||||
|
/// <param name="searchKeyword">搜索关键词(搜索交易摘要和分类)</param>
|
||||||
|
/// <returns>交易记录列表、最后发生时间和最后ID</returns>
|
||||||
|
Task<(List<TransactionRecord> list, DateTime? lastOccurredAt, long lastId)> GetPagedListAsync(DateTime? lastOccurredAt, long? lastId, int pageSize = 20, string? searchKeyword = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取总数
|
||||||
|
/// </summary>
|
||||||
|
Task<long> GetTotalCountAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有不同的交易分类
|
||||||
|
/// </summary>
|
||||||
|
Task<List<string>> GetDistinctClassifyAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有不同的交易子分类
|
||||||
|
/// </summary>
|
||||||
|
Task<List<string>> GetDistinctSubClassifyAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定月份每天的消费统计
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="year">年份</param>
|
||||||
|
/// <param name="month">月份</param>
|
||||||
|
/// <returns>每天的消费笔数和金额</returns>
|
||||||
|
Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsAsync(int year, int month);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定日期范围内的交易记录
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="startDate">开始日期</param>
|
||||||
|
/// <param name="endDate">结束日期</param>
|
||||||
|
/// <returns>交易记录列表</returns>
|
||||||
|
Task<List<TransactionRecord>> GetByDateRangeAsync(DateTime startDate, DateTime endDate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定邮件的交易记录数量
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="emailMessageId">邮件ID</param>
|
||||||
|
/// <returns>交易记录数量</returns>
|
||||||
|
Task<int> GetCountByEmailIdAsync(long emailMessageId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定邮件的交易记录列表
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="emailMessageId">邮件ID</param>
|
||||||
|
/// <returns>交易记录列表</returns>
|
||||||
|
Task<List<TransactionRecord>> GetByEmailIdAsync(long emailMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
|
||||||
|
{
|
||||||
|
public async Task<TransactionRecord?> ExistsByEmailMessageIdAsync(long emailMessageId, DateTime occurredAt)
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(t => t.EmailMessageId == emailMessageId && t.OccurredAt == occurredAt)
|
||||||
|
.FirstAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TransactionRecord?> ExistsByImportNoAsync(string importNo, string importFrom)
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(t => t.ImportNo == importNo && t.ImportFrom == importFrom)
|
||||||
|
.FirstAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(List<TransactionRecord> list, DateTime? lastOccurredAt, long lastId)> GetPagedListAsync(DateTime? lastOccurredAt, long? lastId, int pageSize = 20, string? searchKeyword = null)
|
||||||
|
{
|
||||||
|
var query = FreeSql.Select<TransactionRecord>();
|
||||||
|
|
||||||
|
// 如果提供了搜索关键词,则添加搜索条件
|
||||||
|
if (!string.IsNullOrWhiteSpace(searchKeyword))
|
||||||
|
{
|
||||||
|
query = query.Where(t => t.Reason.Contains(searchKeyword) ||
|
||||||
|
t.Classify.Contains(searchKeyword) ||
|
||||||
|
t.SubClassify.Contains(searchKeyword) ||
|
||||||
|
t.Card.Contains(searchKeyword) ||
|
||||||
|
t.ImportFrom.Contains(searchKeyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果提供了游标,则获取小于游标位置的记录
|
||||||
|
if (lastOccurredAt.HasValue && lastId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(t => t.OccurredAt < lastOccurredAt.Value ||
|
||||||
|
(t.OccurredAt == lastOccurredAt.Value && t.Id < lastId.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = await query
|
||||||
|
.OrderByDescending(t => t.OccurredAt)
|
||||||
|
.OrderByDescending(t => t.Id)
|
||||||
|
.Page(1, pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var lastRecord = list.Count > 0 ? list.Last() : null;
|
||||||
|
return (list, lastRecord?.OccurredAt, lastRecord?.Id ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> GetTotalCountAsync()
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<TransactionRecord>().CountAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> GetDistinctClassifyAsync()
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(t => !string.IsNullOrEmpty(t.Classify))
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(t => t.Classify);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> GetDistinctSubClassifyAsync()
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(t => !string.IsNullOrEmpty(t.SubClassify))
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(t => t.SubClassify);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<string, (int count, decimal amount)>> GetDailyStatisticsAsync(int year, int month)
|
||||||
|
{
|
||||||
|
var startDate = new DateTime(year, month, 1);
|
||||||
|
var endDate = startDate.AddMonths(1);
|
||||||
|
|
||||||
|
var records = await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate)
|
||||||
|
.Where(t => t.Type == TransactionType.Expense || t.Type == TransactionType.Income) // 统计消费和收入
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var statistics = records
|
||||||
|
.GroupBy(t => t.OccurredAt.ToString("yyyy-MM-dd"))
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g =>
|
||||||
|
{
|
||||||
|
// 分别统计收入和支出
|
||||||
|
var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
|
||||||
|
var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount);
|
||||||
|
// 净额 = 收入 - 支出(消费大于收入时为负数)
|
||||||
|
var netAmount = income - expense;
|
||||||
|
return (count: g.Count(), amount: netAmount);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return statistics;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TransactionRecord>> GetByDateRangeAsync(DateTime startDate, DateTime endDate)
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate)
|
||||||
|
.OrderBy(t => t.OccurredAt)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetCountByEmailIdAsync(long emailMessageId)
|
||||||
|
{
|
||||||
|
return (int)await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(t => t.EmailMessageId == emailMessageId)
|
||||||
|
.CountAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TransactionRecord>> GetByEmailIdAsync(long emailMessageId)
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(t => t.EmailMessageId == emailMessageId)
|
||||||
|
.OrderBy(t => t.OccurredAt)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Service/AppSettingModel/AISettings.cs
Normal file
8
Service/AppSettingModel/AISettings.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Service.AppSettingModel;
|
||||||
|
|
||||||
|
public class AISettings
|
||||||
|
{
|
||||||
|
public string Endpoint { get; set; } = string.Empty;
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public string Model { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
14
Service/AppSettingModel/EmailConfigItem.cs
Normal file
14
Service/AppSettingModel/EmailConfigItem.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Service.AppSettingModel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮箱配置项
|
||||||
|
/// </summary>
|
||||||
|
public class EmailConfigItem
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
public string ImapHost { get; set; } = string.Empty;
|
||||||
|
public int ImapPort { get; set; } = 993;
|
||||||
|
public bool UseSsl { get; set; } = true;
|
||||||
|
}
|
||||||
11
Service/AppSettingModel/EmailSettings.cs
Normal file
11
Service/AppSettingModel/EmailSettings.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Service.AppSettingModel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮箱设置配置
|
||||||
|
/// </summary>
|
||||||
|
public class EmailSettings
|
||||||
|
{
|
||||||
|
public int CheckIntervalMinutes { get; set; } = 1;
|
||||||
|
public EmailConfigItem[] SmtpList { get; set; } = [];
|
||||||
|
public string[] FilterFromAddresses { get; set; } = [];
|
||||||
|
}
|
||||||
236
Service/EmailBackgroundService.cs
Normal file
236
Service/EmailBackgroundService.cs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using MimeKit;
|
||||||
|
|
||||||
|
namespace Service;
|
||||||
|
|
||||||
|
public interface IEmailBackgroundService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 手动触发邮件同步
|
||||||
|
/// </summary>
|
||||||
|
Task SyncEmailsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EmailBackgroundService(
|
||||||
|
IOptions<EmailSettings> emailSettings,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
IEmailHandleService emailHandleService,
|
||||||
|
ILogger<EmailBackgroundService> logger)
|
||||||
|
: BackgroundWorker, IEmailBackgroundService
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, IEmailFetchService> _emailFetchServices = new();
|
||||||
|
private bool _isInitialized;
|
||||||
|
|
||||||
|
protected override async void OnDoWork(DoWorkEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 启动时建立所有连接
|
||||||
|
await InitializeConnectionsAsync();
|
||||||
|
|
||||||
|
while (!CancellationPending)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await FetchAndPostCmbTransactionsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "后台任务执行出错");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 Thread.Sleep 在异步操作中不阻塞
|
||||||
|
Thread.Sleep(1000 * 60 * 10); // 每10分钟执行一次任务
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "后台服务工作线程出错");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// 停止时断开所有连接
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DisconnectAllAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "断开连接时出错");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化所有邮箱连接
|
||||||
|
/// </summary>
|
||||||
|
private async Task InitializeConnectionsAsync()
|
||||||
|
{
|
||||||
|
if (_isInitialized)
|
||||||
|
{
|
||||||
|
logger.LogWarning("连接已初始化,跳过重复初始化");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (emailSettings.Value.SmtpList.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未配置邮箱账户,无法初始化连接");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("开始初始化 {EmailCount} 个邮箱连接...", emailSettings.Value.SmtpList.Length);
|
||||||
|
|
||||||
|
// 并行初始化所有邮箱连接
|
||||||
|
var tasks = emailSettings.Value.SmtpList.Select(async emailConfig =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var emailFetchService = ActivatorUtilities.CreateInstance<EmailFetchService>(serviceProvider);
|
||||||
|
var success = await emailFetchService.ConnectAsync(
|
||||||
|
emailConfig.ImapHost,
|
||||||
|
emailConfig.ImapPort,
|
||||||
|
emailConfig.UseSsl,
|
||||||
|
emailConfig.Email,
|
||||||
|
emailConfig.Password);
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
_emailFetchServices[emailConfig.Email] = emailFetchService;
|
||||||
|
logger.LogInformation("邮箱 {Email} 连接建立成功", emailConfig.Email);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogError("邮箱 {Email} 连接建立失败", emailConfig.Email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "初始化邮箱 {Email} 连接时出错", emailConfig.Email);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
_isInitialized = true;
|
||||||
|
logger.LogInformation("所有邮箱连接初始化完成,成功连接 {Count} 个邮箱", _emailFetchServices.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "初始化邮箱连接失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 断开所有邮箱连接
|
||||||
|
/// </summary>
|
||||||
|
private async Task DisconnectAllAsync()
|
||||||
|
{
|
||||||
|
logger.LogInformation("开始断开所有邮箱连接...");
|
||||||
|
|
||||||
|
var tasks = _emailFetchServices.Select(async kvp =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await kvp.Value.DisconnectAsync();
|
||||||
|
logger.LogInformation("邮箱 {Email} 已断开连接", kvp.Key);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "断开邮箱 {Email} 连接时出错", kvp.Key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
_emailFetchServices.Clear();
|
||||||
|
_isInitialized = false;
|
||||||
|
logger.LogInformation("所有邮箱连接已断开");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手动触发邮件同步(公开方法)
|
||||||
|
/// </summary>
|
||||||
|
public async Task SyncEmailsAsync()
|
||||||
|
{
|
||||||
|
await FetchAndPostCmbTransactionsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 抓取并处理招商银行邮件交易
|
||||||
|
/// </summary>
|
||||||
|
private async Task FetchAndPostCmbTransactionsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_emailFetchServices.Count == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("没有可用的邮箱连接,跳过抓取");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("开始抓取 {EmailCount} 个邮箱的邮件", _emailFetchServices.Count);
|
||||||
|
|
||||||
|
// 并行处理多个邮箱
|
||||||
|
var tasks = _emailFetchServices.Select(async kvp =>
|
||||||
|
{
|
||||||
|
var email = kvp.Key;
|
||||||
|
var emailFetchService = kvp.Value;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 获取未读邮件
|
||||||
|
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
||||||
|
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
||||||
|
|
||||||
|
foreach (var (message, uid) in unreadMessages)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logger.LogDebug("邮件信息 - 发送者: {From}, 主题: {Subject}, 接收时间: {Date}",
|
||||||
|
message.From, message.Subject, message.Date);
|
||||||
|
logger.LogDebug("邮件内容预览: {Preview}", GetEmailBodyPreview(message));
|
||||||
|
|
||||||
|
if (await emailHandleService.HandleEmailAsync(
|
||||||
|
message.From.ToString(),
|
||||||
|
message.Subject,
|
||||||
|
message.Date.DateTime,
|
||||||
|
message.TextBody ?? message.HtmlBody ?? string.Empty
|
||||||
|
))
|
||||||
|
{
|
||||||
|
// 标记邮件为已读
|
||||||
|
await emailFetchService.MarkAsReadAsync(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "处理邮件时出错");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("邮箱 {Email} 邮件抓取完成", email);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "邮箱 {Email} 邮件抓取失败", email);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
logger.LogInformation("所有邮箱邮件抓取完成");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "抓取邮件异常");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取邮件内容预览
|
||||||
|
/// </summary>
|
||||||
|
private static string GetEmailBodyPreview(MimeMessage message)
|
||||||
|
{
|
||||||
|
var body = message.HtmlBody ?? message.TextBody ?? string.Empty;
|
||||||
|
var preview = body.Length > 100 ? body.Substring(0, 100) + "..." : body;
|
||||||
|
return preview.Replace("\n", " ").Replace("\r", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
253
Service/EmailFetchService.cs
Normal file
253
Service/EmailFetchService.cs
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
using MailKit;
|
||||||
|
using MailKit.Net.Imap;
|
||||||
|
using MailKit.Search;
|
||||||
|
using MailKit.Security;
|
||||||
|
using MimeKit;
|
||||||
|
|
||||||
|
namespace Service;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮件抓取服务接口
|
||||||
|
/// </summary>
|
||||||
|
public interface IEmailFetchService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 连接状态
|
||||||
|
/// </summary>
|
||||||
|
bool IsConnected { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 连接到邮件服务器
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> ConnectAsync(string host, int port, bool useSsl, string email, string password);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从收件箱获取未读邮件
|
||||||
|
/// </summary>
|
||||||
|
Task<List<(MimeMessage Message, UniqueId Uid)>> FetchUnreadMessagesAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有邮件
|
||||||
|
/// </summary>
|
||||||
|
Task<List<(MimeMessage Message, UniqueId Uid)>> FetchAllMessagesAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 断开与邮件服务器的连接
|
||||||
|
/// </summary>
|
||||||
|
Task DisconnectAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 标记邮件为已读
|
||||||
|
/// </summary>
|
||||||
|
Task MarkAsReadAsync(UniqueId uid);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 确保连接有效,如断开则自动重连
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> EnsureConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮件抓取服务实现
|
||||||
|
/// </summary>
|
||||||
|
public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchService
|
||||||
|
{
|
||||||
|
private ImapClient? _imapClient;
|
||||||
|
private string _host = string.Empty;
|
||||||
|
private int _port;
|
||||||
|
private bool _useSsl;
|
||||||
|
private string _email = string.Empty;
|
||||||
|
private string _password = string.Empty;
|
||||||
|
private DateTime _lastKeepAlive = DateTime.MinValue;
|
||||||
|
private const int KeepAliveIntervalSeconds = 300; // 5分钟发送一次KeepAlive
|
||||||
|
private readonly ILogger<EmailFetchService> _logger = logger;
|
||||||
|
|
||||||
|
public bool IsConnected => _imapClient?.IsConnected == true;
|
||||||
|
|
||||||
|
public async Task<bool> ConnectAsync(string host, int port, bool useSsl, string email, string password)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 保存连接信息用于自动重连
|
||||||
|
_host = host;
|
||||||
|
_port = port;
|
||||||
|
_useSsl = useSsl;
|
||||||
|
_email = email;
|
||||||
|
_password = password;
|
||||||
|
|
||||||
|
// 如果已连接,先断开
|
||||||
|
if (_imapClient?.IsConnected == true)
|
||||||
|
{
|
||||||
|
await DisconnectAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_imapClient = new ImapClient();
|
||||||
|
|
||||||
|
if (useSsl)
|
||||||
|
{
|
||||||
|
await _imapClient.ConnectAsync(host, port, SecureSocketOptions.SslOnConnect);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _imapClient.ConnectAsync(host, port, SecureSocketOptions.StartTlsWhenAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _imapClient.AuthenticateAsync(email, password);
|
||||||
|
_logger.LogInformation("邮箱 {Email} 连接成功", email);
|
||||||
|
_lastKeepAlive = DateTime.UtcNow;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "邮件连接失败 ({Email}): {Message}", email, ex.Message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<(MimeMessage Message, UniqueId Uid)>> FetchUnreadMessagesAsync()
|
||||||
|
{
|
||||||
|
var result = new List<(MimeMessage, UniqueId)>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 确保连接有效
|
||||||
|
if (!await EnsureConnectedAsync())
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var inbox = _imapClient?.Inbox;
|
||||||
|
if (inbox == null)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
await inbox.OpenAsync(FolderAccess.ReadWrite);
|
||||||
|
|
||||||
|
// 查询未读邮件
|
||||||
|
var unreadUids = await inbox.SearchAsync(SearchQuery.NotSeen);
|
||||||
|
|
||||||
|
foreach (var uid in unreadUids)
|
||||||
|
{
|
||||||
|
var message = await inbox.GetMessageAsync(uid);
|
||||||
|
result.Add((message, uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "获取未读邮件失败: {Message}", ex.Message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<(MimeMessage Message, UniqueId Uid)>> FetchAllMessagesAsync()
|
||||||
|
{
|
||||||
|
var result = new List<(MimeMessage, UniqueId)>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 确保连接有效
|
||||||
|
if (!await EnsureConnectedAsync())
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var inbox = _imapClient?.Inbox;
|
||||||
|
if (inbox == null)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
await inbox.OpenAsync(FolderAccess.ReadWrite);
|
||||||
|
|
||||||
|
var uids = await inbox.SearchAsync(SearchQuery.All);
|
||||||
|
|
||||||
|
foreach (var uid in uids)
|
||||||
|
{
|
||||||
|
var message = await inbox.GetMessageAsync(uid);
|
||||||
|
result.Add((message, uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "获取所有邮件失败: {Message}", ex.Message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisconnectAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_imapClient?.IsConnected == true)
|
||||||
|
{
|
||||||
|
await _imapClient.DisconnectAsync(true);
|
||||||
|
_logger.LogInformation("邮箱 {Email} 已断开连接", _email);
|
||||||
|
}
|
||||||
|
|
||||||
|
_imapClient?.Dispose();
|
||||||
|
_imapClient = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "断开连接失败 ({Email}): {Message}", _email, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkAsReadAsync(UniqueId uid)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await EnsureConnectedAsync())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var inbox = _imapClient?.Inbox;
|
||||||
|
if (inbox == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// 打开收件箱以读写模式
|
||||||
|
await inbox.OpenAsync(FolderAccess.ReadWrite);
|
||||||
|
|
||||||
|
// 标记邮件为已读(设置Seen标记)
|
||||||
|
await inbox.AddFlagsAsync(uid, MessageFlags.Seen, silent: false);
|
||||||
|
|
||||||
|
_logger.LogDebug("邮件 {Uid} 标记已读操作已提交", uid);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "标记邮件为已读失败: {Message}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 确保连接有效,如果断开则自动重连
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> EnsureConnectedAsync()
|
||||||
|
{
|
||||||
|
if (_imapClient?.IsConnected == true)
|
||||||
|
{
|
||||||
|
// 定期发送NOOP保持连接活跃(防止超时断开)
|
||||||
|
var timeSinceLastKeepAlive = (DateTime.UtcNow - _lastKeepAlive).TotalSeconds;
|
||||||
|
if (timeSinceLastKeepAlive > KeepAliveIntervalSeconds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _imapClient.NoOpAsync();
|
||||||
|
_lastKeepAlive = DateTime.UtcNow;
|
||||||
|
_logger.LogDebug("邮箱 {Email} KeepAlive 保活信号已发送", _email);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// NOOP失败,说明连接已断开,继续重连逻辑
|
||||||
|
_logger.LogWarning(ex, "KeepAlive 失败,连接已断开: {Message}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _imapClient?.IsConnected == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_host) || string.IsNullOrEmpty(_email))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("未初始化连接信息,无法自动重连");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("检测到连接断开,尝试重新连接到 {Email}...", _email);
|
||||||
|
return await ConnectAsync(_host, _port, _useSsl, _email, _password);
|
||||||
|
}
|
||||||
|
}
|
||||||
275
Service/EmailHandleService.cs
Normal file
275
Service/EmailHandleService.cs
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
using Service.EmailParseServices;
|
||||||
|
|
||||||
|
namespace Service;
|
||||||
|
|
||||||
|
public interface IEmailHandleService
|
||||||
|
{
|
||||||
|
Task<bool> HandleEmailAsync(
|
||||||
|
string from,
|
||||||
|
string subject,
|
||||||
|
DateTime date,
|
||||||
|
string body
|
||||||
|
);
|
||||||
|
|
||||||
|
Task<bool> RefreshTransactionRecordsAsync(long emailMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EmailHandleService(
|
||||||
|
IOptions<EmailSettings> emailSettings,
|
||||||
|
ILogger<EmailHandleService> logger,
|
||||||
|
IEmailMessageRepository emailRepo,
|
||||||
|
ITransactionRecordRepository trxRepo,
|
||||||
|
IEnumerable<IEmailParseServices> emailParsers
|
||||||
|
) : IEmailHandleService
|
||||||
|
{
|
||||||
|
public async Task<bool> HandleEmailAsync(
|
||||||
|
string from,
|
||||||
|
string subject,
|
||||||
|
DateTime date,
|
||||||
|
string body
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var emailMessage = await SaveEmailAsync(from, subject, date, body);
|
||||||
|
|
||||||
|
if (emailMessage == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("邮件保存失败,无法继续处理");
|
||||||
|
}
|
||||||
|
|
||||||
|
var filterForm = emailSettings.Value.FilterFromAddresses;
|
||||||
|
if (filterForm.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未配置邮件过滤条件,跳过账单处理");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filterForm.Any(f => from.Contains(f)))
|
||||||
|
{
|
||||||
|
logger.LogInformation("邮件不符合发件人过滤条件,跳过账单处理");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = await ParseEmailBodyAsync(
|
||||||
|
from,
|
||||||
|
string.IsNullOrEmpty(emailMessage.Body)
|
||||||
|
? emailMessage.HtmlBody
|
||||||
|
: emailMessage.Body
|
||||||
|
);
|
||||||
|
if (parsed == null || parsed.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
|
||||||
|
|
||||||
|
bool allSuccess = true;
|
||||||
|
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
|
||||||
|
{
|
||||||
|
logger.LogInformation("处理交易记录: 卡号 {Card}, 交易原因 {Reason}, 金额 {Amount}, 余额 {Balance}, 类型 {Type}", card, reason, amount, balance, type);
|
||||||
|
|
||||||
|
var success = await SaveTransactionRecordAsync(
|
||||||
|
card,
|
||||||
|
reason,
|
||||||
|
amount,
|
||||||
|
balance,
|
||||||
|
type,
|
||||||
|
occurredAt ?? date,
|
||||||
|
emailMessage.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
allSuccess = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RefreshTransactionRecordsAsync(long emailMessageId)
|
||||||
|
{
|
||||||
|
var emailMessage = await emailRepo.GetByIdAsync(emailMessageId);
|
||||||
|
|
||||||
|
if (emailMessage == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未找到指定ID的邮件记录,无法刷新交易记录,ID: {Id}", emailMessageId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filterForm = emailSettings.Value.FilterFromAddresses;
|
||||||
|
if (filterForm.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未配置邮件过滤条件,跳过账单处理");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filterForm.Any(f => emailMessage.From.Contains(f)))
|
||||||
|
{
|
||||||
|
logger.LogInformation("邮件不符合发件人过滤条件,跳过账单处理");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = await ParseEmailBodyAsync(
|
||||||
|
emailMessage.From,
|
||||||
|
string.IsNullOrEmpty(emailMessage.Body)
|
||||||
|
? emailMessage.HtmlBody
|
||||||
|
: emailMessage.Body
|
||||||
|
);
|
||||||
|
if (parsed == null || parsed.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
|
||||||
|
|
||||||
|
bool allSuccess = true;
|
||||||
|
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
|
||||||
|
{
|
||||||
|
logger.LogInformation("刷新交易记录: 卡号 {Card}, 交易原因 {Reason}, 金额 {Amount}, 余额 {Balance}, 类型 {Type}", card, reason, amount, balance, type);
|
||||||
|
|
||||||
|
var success = await SaveTransactionRecordAsync(
|
||||||
|
card,
|
||||||
|
reason,
|
||||||
|
amount,
|
||||||
|
balance,
|
||||||
|
type,
|
||||||
|
occurredAt ?? emailMessage.ReceivedDate,
|
||||||
|
emailMessage.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
allSuccess = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<EmailMessage?> SaveEmailAsync(
|
||||||
|
string from,
|
||||||
|
string subject,
|
||||||
|
DateTime date,
|
||||||
|
string body
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var emailEntity = new EmailMessage
|
||||||
|
{
|
||||||
|
From = from,
|
||||||
|
Subject = subject,
|
||||||
|
|
||||||
|
ReceivedDate = date,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 正则判断是否为HTML内容
|
||||||
|
if (Regex.IsMatch(body, @"<[^>]+>"))
|
||||||
|
{
|
||||||
|
emailEntity.HtmlBody = body;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
emailEntity.Body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existsEmail = await emailRepo.ExistsAsync(from, subject, date, body);
|
||||||
|
if (existsEmail != null)
|
||||||
|
{
|
||||||
|
logger.LogInformation("检测到重复邮件,跳过入库:{From} | {Subject} | {Date}", from, subject, date);
|
||||||
|
return existsEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok = await emailRepo.AddAsync(emailEntity);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
logger.LogInformation("邮件已落库,ID: {Id}", emailEntity.Id);
|
||||||
|
return emailEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogError("邮件落库失败");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 原始邮件落库失败不阻塞交易记录,但记录告警
|
||||||
|
logger.LogWarning(ex, "原始邮件落库失败");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> SaveTransactionRecordAsync(
|
||||||
|
string card,
|
||||||
|
string reason,
|
||||||
|
decimal amount,
|
||||||
|
decimal balance,
|
||||||
|
TransactionType type,
|
||||||
|
DateTime occurredAt,
|
||||||
|
long emailMessageId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// 根据 emailMessageId 检查是否已存在记录:存在则更新,否则新增
|
||||||
|
var existing = await trxRepo.ExistsByEmailMessageIdAsync(emailMessageId, occurredAt);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.Card = card;
|
||||||
|
existing.Reason = reason;
|
||||||
|
existing.Amount = amount;
|
||||||
|
existing.Balance = balance;
|
||||||
|
existing.Type = type;
|
||||||
|
existing.OccurredAt = occurredAt;
|
||||||
|
|
||||||
|
var updated = await trxRepo.UpdateAsync(existing);
|
||||||
|
if (updated)
|
||||||
|
{
|
||||||
|
logger.LogInformation("交易记录已更新,卡号 {Card}, 金额 {Amount}", card, amount);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("交易记录更新失败,卡号 {Card}, 金额 {Amount}", card, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trx = new TransactionRecord
|
||||||
|
{
|
||||||
|
Card = card,
|
||||||
|
Reason = reason,
|
||||||
|
Amount = amount,
|
||||||
|
Balance = balance,
|
||||||
|
Type = type,
|
||||||
|
OccurredAt = occurredAt,
|
||||||
|
EmailMessageId = emailMessageId,
|
||||||
|
ImportFrom = $"邮件"
|
||||||
|
};
|
||||||
|
|
||||||
|
var inserted = await trxRepo.AddAsync(trx);
|
||||||
|
if (inserted)
|
||||||
|
{
|
||||||
|
logger.LogInformation("交易记录已落库,卡号 {Card}, 金额 {Amount}", card, amount);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("交易记录落库失败,卡号 {Card}, 金额 {Amount}", card, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?> ParseEmailBodyAsync(string from, string body)
|
||||||
|
{
|
||||||
|
var service = emailParsers.FirstOrDefault(s => s.CanParse(from, body));
|
||||||
|
|
||||||
|
if (service == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未找到合适的邮件解析服务,跳过解析");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await service.ParseAsync(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
Service/EmailParseServices/EmailParseForm95555.cs
Normal file
71
Service/EmailParseServices/EmailParseForm95555.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
namespace Service.EmailParseServices;
|
||||||
|
|
||||||
|
public class EmailParseForm95555(
|
||||||
|
ILogger<EmailParseForm95555> logger,
|
||||||
|
IOpenAiService openAiService
|
||||||
|
) : EmailParseServicesBase(logger, openAiService)
|
||||||
|
{
|
||||||
|
public override bool CanParse(string from, string body)
|
||||||
|
{
|
||||||
|
if (!from.Contains("95555@message.cmbchina.com"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不能包含HTML标签
|
||||||
|
if (Regex.IsMatch(body, "<.*?>"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<(
|
||||||
|
string card,
|
||||||
|
string reason,
|
||||||
|
decimal amount,
|
||||||
|
decimal balance,
|
||||||
|
TransactionType type,
|
||||||
|
DateTime? occurredAt
|
||||||
|
)[]> ParseEmailContentAsync(string emailContent)
|
||||||
|
{
|
||||||
|
var pattern = "您账户(?<card>\\d+)于.*?(?<type>收入|支出|消费|转入|转出)?.*?在?(?<reason>.+?)(?<amount>\\d+\\.\\d{1,2})元,余额(?<balance>\\d+\\.\\d{1,2})";
|
||||||
|
|
||||||
|
var matches = Regex.Matches(emailContent, pattern);
|
||||||
|
|
||||||
|
if (matches.Count <= 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未能从招商银行邮件内容中解析出交易信息");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = new List<(
|
||||||
|
string card,
|
||||||
|
string reason,
|
||||||
|
decimal amount,
|
||||||
|
decimal balance,
|
||||||
|
TransactionType type,
|
||||||
|
DateTime? occurredAt
|
||||||
|
)>();
|
||||||
|
|
||||||
|
foreach (Match match in matches)
|
||||||
|
{
|
||||||
|
var card = match.Groups["card"].Value;
|
||||||
|
var reason = match.Groups["reason"].Value;
|
||||||
|
var amountStr = match.Groups["amount"].Value;
|
||||||
|
var balanceStr = match.Groups["balance"].Value;
|
||||||
|
var typeStr = match.Groups["type"].Value;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(card) &&
|
||||||
|
!string.IsNullOrEmpty(reason) &&
|
||||||
|
decimal.TryParse(amountStr, out var amount) &&
|
||||||
|
decimal.TryParse(balanceStr, out var balance))
|
||||||
|
{
|
||||||
|
var type = DetermineTransactionType(typeStr, reason, amount);
|
||||||
|
results.Add((card, reason, amount, balance, type, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
153
Service/EmailParseServices/EmailParseFormCCSVC.cs
Normal file
153
Service/EmailParseServices/EmailParseFormCCSVC.cs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
|
namespace Service.EmailParseServices;
|
||||||
|
|
||||||
|
public class EmailParseFormCCSVC(
|
||||||
|
ILogger<EmailParseFormCCSVC> logger,
|
||||||
|
IOpenAiService openAiService
|
||||||
|
) : EmailParseServicesBase(logger, openAiService)
|
||||||
|
{
|
||||||
|
public override bool CanParse(string from, string body)
|
||||||
|
{
|
||||||
|
if (!from.Contains("ccsvc@message.cmbchina.com"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必须包含HTML标签
|
||||||
|
if (!Regex.IsMatch(body, "<.*?>"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<(
|
||||||
|
string card,
|
||||||
|
string reason,
|
||||||
|
decimal amount,
|
||||||
|
decimal balance,
|
||||||
|
TransactionType type,
|
||||||
|
DateTime? occurredAt
|
||||||
|
)[]> ParseEmailContentAsync(string emailContent)
|
||||||
|
{
|
||||||
|
var doc = new HtmlDocument();
|
||||||
|
doc.LoadHtml(emailContent);
|
||||||
|
|
||||||
|
var result = new List<(string, string, decimal, decimal, TransactionType, DateTime?)>();
|
||||||
|
|
||||||
|
// 1. Get Date
|
||||||
|
var dateNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '您的消费明细如下')]");
|
||||||
|
if (dateNode == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Date node not found");
|
||||||
|
return Array.Empty<(string, string, decimal, decimal, TransactionType, DateTime?)>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateText = dateNode.InnerText.Trim();
|
||||||
|
// "2025/12/21 您的消费明细如下:"
|
||||||
|
var dateMatch = Regex.Match(dateText, @"\d{4}/\d{1,2}/\d{1,2}");
|
||||||
|
if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Failed to parse date from: {DateText}", dateText);
|
||||||
|
return Array.Empty<(string, string, decimal, decimal, TransactionType, DateTime?)>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get Balance (Available Limit)
|
||||||
|
decimal balance = 0;
|
||||||
|
// Find "可用额度" label
|
||||||
|
var limitLabelNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '可用额度')]");
|
||||||
|
if (limitLabelNode != null)
|
||||||
|
{
|
||||||
|
// Go up to TR
|
||||||
|
var tr = limitLabelNode.Ancestors("tr").FirstOrDefault();
|
||||||
|
if (tr != null)
|
||||||
|
{
|
||||||
|
var prevTr = tr.PreviousSibling;
|
||||||
|
while (prevTr != null && prevTr.Name != "tr") prevTr = prevTr.PreviousSibling;
|
||||||
|
|
||||||
|
if (prevTr != null)
|
||||||
|
{
|
||||||
|
var balanceNode = prevTr.SelectSingleNode(".//font[contains(text(), '¥')]");
|
||||||
|
if (balanceNode != null)
|
||||||
|
{
|
||||||
|
var balanceStr = balanceNode.InnerText.Replace("¥", "").Replace(",", "").Trim();
|
||||||
|
decimal.TryParse(balanceStr, out balance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get Transactions
|
||||||
|
var transactionNodes = doc.DocumentNode.SelectNodes("//span[@id='fixBand4']");
|
||||||
|
if (transactionNodes != null)
|
||||||
|
{
|
||||||
|
foreach (var node in transactionNodes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Time
|
||||||
|
var timeNode = node.SelectSingleNode(".//span[@id='fixBand5']//font");
|
||||||
|
var timeText = timeNode?.InnerText.Trim(); // "10:13:43"
|
||||||
|
|
||||||
|
DateTime? occurredAt = date;
|
||||||
|
if (!string.IsNullOrEmpty(timeText) && DateTime.TryParse($"{date:yyyy-MM-dd} {timeText}", out var dt))
|
||||||
|
{
|
||||||
|
occurredAt = dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info Block
|
||||||
|
var infoNode = node.SelectSingleNode(".//span[@id='fixBand12']");
|
||||||
|
if (infoNode == null) continue;
|
||||||
|
|
||||||
|
// Amount
|
||||||
|
var amountNode = infoNode.SelectSingleNode(".//font[contains(text(), 'CNY')]");
|
||||||
|
var amountText = amountNode?.InnerText.Replace("CNY", "").Replace(" ", "").Trim();
|
||||||
|
if (!decimal.TryParse(amountText, out var amount))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
var descNode = infoNode.SelectSingleNode(".//tr[2]//font");
|
||||||
|
var descText = descNode?.InnerText ?? "";
|
||||||
|
// Replace and non-breaking space (\u00A0) with normal space
|
||||||
|
descText = descText.Replace(" ", " ");
|
||||||
|
descText = HtmlEntity.DeEntitize(descText).Replace((char)160, ' ').Trim();
|
||||||
|
|
||||||
|
// Parse Description: "尾号4390 消费 财付通-luckincoffee瑞幸咖啡"
|
||||||
|
var parts = descText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
string card = "";
|
||||||
|
string reason = descText;
|
||||||
|
TransactionType type = TransactionType.Expense;
|
||||||
|
|
||||||
|
if (parts.Length > 0 && parts[0].StartsWith("尾号"))
|
||||||
|
{
|
||||||
|
card = parts[0].Replace("尾号", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.Length > 1)
|
||||||
|
{
|
||||||
|
var typeStr = parts[1];
|
||||||
|
type = DetermineTransactionType(typeStr, reason, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.Length > 2)
|
||||||
|
{
|
||||||
|
reason = string.Join(" ", parts.Skip(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add((card, reason, amount, balance, type, occurredAt));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error parsing transaction node");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Task.FromResult(result.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
211
Service/EmailParseServices/IEmailParseServices.cs
Normal file
211
Service/EmailParseServices/IEmailParseServices.cs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
namespace Service.EmailParseServices;
|
||||||
|
|
||||||
|
public interface IEmailParseServices
|
||||||
|
{
|
||||||
|
bool CanParse(string from, string body);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析邮件内容,提取交易信息
|
||||||
|
/// </summary>
|
||||||
|
Task<(
|
||||||
|
string card,
|
||||||
|
string reason,
|
||||||
|
decimal amount,
|
||||||
|
decimal balance,
|
||||||
|
TransactionType type,
|
||||||
|
DateTime? occurredAt
|
||||||
|
)[]> ParseAsync(string emailContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class EmailParseServicesBase(
|
||||||
|
ILogger<EmailParseServicesBase> logger,
|
||||||
|
IOpenAiService openAiService
|
||||||
|
) : IEmailParseServices
|
||||||
|
{
|
||||||
|
public abstract bool CanParse(string from, string body);
|
||||||
|
|
||||||
|
public async Task<(
|
||||||
|
string card,
|
||||||
|
string reason,
|
||||||
|
decimal amount,
|
||||||
|
decimal balance,
|
||||||
|
TransactionType type,
|
||||||
|
DateTime? occurredAt
|
||||||
|
)[]> ParseAsync(string emailContent)
|
||||||
|
{
|
||||||
|
var result = await ParseEmailContentAsync(emailContent);
|
||||||
|
|
||||||
|
if (result.Length > 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation("使用规则成功解析邮件内容,提取到 {Count} 条交易记录", result.Length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("规则解析邮件内容失败,尝试使用AI进行解析");
|
||||||
|
// AI兜底
|
||||||
|
result = await ParseByAiAsync(emailContent) ?? [];
|
||||||
|
|
||||||
|
if(result.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("AI解析邮件内容也未能提取到任何交易记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract Task<(
|
||||||
|
string card,
|
||||||
|
string reason,
|
||||||
|
decimal amount,
|
||||||
|
decimal balance,
|
||||||
|
TransactionType type,
|
||||||
|
DateTime? occurredAt
|
||||||
|
)[]> ParseEmailContentAsync(string emailContent);
|
||||||
|
|
||||||
|
private async Task<(
|
||||||
|
string card,
|
||||||
|
string reason,
|
||||||
|
decimal amount,
|
||||||
|
decimal balance,
|
||||||
|
TransactionType type,
|
||||||
|
DateTime? occurredAt
|
||||||
|
)[]?> ParseByAiAsync(string body)
|
||||||
|
{
|
||||||
|
var systemPrompt = "你是一个信息抽取助手。仅输出严格的JSON数组,不要包含任何多余文本。每个交易记录包含字段: card(字符串), reason(字符串), amount(数字), balance(数字), type(字符串,值为'收入'或'支出'), occurredAt(字符串,yyyy-MM-dd HH:mm:ss格式日期时间)。如果缺失,请推断或置空。";
|
||||||
|
var userPrompt = $"从下面这封银行账单相关邮件正文中提取所有交易记录,返回JSON数组格式,每个元素包含: card, reason, amount, balance, type(收入或支出), occurredAt(非必要)。正文如下:\n\n{body}";
|
||||||
|
|
||||||
|
var contentText = await openAiService.ChatAsync(systemPrompt, userPrompt);
|
||||||
|
if (string.IsNullOrWhiteSpace(contentText))
|
||||||
|
{
|
||||||
|
logger.LogWarning("AI未返回任何内容,无法解析邮件");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogDebug("AI返回的内容: {Content}", contentText);
|
||||||
|
// 清理可能的 markdown 代码块标记
|
||||||
|
contentText = contentText.Trim();
|
||||||
|
if (contentText.StartsWith("```"))
|
||||||
|
{
|
||||||
|
// 移除开头的 ```json 或 ```
|
||||||
|
var firstNewLine = contentText.IndexOf('\n');
|
||||||
|
if (firstNewLine > 0)
|
||||||
|
{
|
||||||
|
contentText = contentText.Substring(firstNewLine + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除结尾的 ```
|
||||||
|
if (contentText.EndsWith("```"))
|
||||||
|
{
|
||||||
|
contentText = contentText.Substring(0, contentText.Length - 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
contentText = contentText.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentText 期望是 JSON 数组
|
||||||
|
using var jsonDoc = JsonDocument.Parse(contentText);
|
||||||
|
var arrayElement = jsonDoc.RootElement;
|
||||||
|
|
||||||
|
// 如果返回的是单个对象而不是数组,尝试兼容处理
|
||||||
|
if (arrayElement.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
logger.LogWarning("AI返回的内容是单个对象而非数组,尝试兼容处理");
|
||||||
|
var result = ParseSingleRecord(arrayElement);
|
||||||
|
return result != null ? [result.Value] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arrayElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
logger.LogWarning("AI返回的内容不是JSON数组,无法解析邮件");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = new List<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)>();
|
||||||
|
|
||||||
|
foreach (var obj in arrayElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
var record = ParseSingleRecord(obj);
|
||||||
|
if (record != null)
|
||||||
|
{
|
||||||
|
logger.LogInformation("解析到一条交易记录: {@Record}", record.Value);
|
||||||
|
results.Add(record.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("使用AI成功解析邮件内容,提取到 {Count} 条交易记录", results.Count);
|
||||||
|
return results.Count > 0 ? results.ToArray() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)? ParseSingleRecord(JsonElement obj)
|
||||||
|
{
|
||||||
|
string card = obj.TryGetProperty("card", out var pCard) ? pCard.GetString() ?? string.Empty : string.Empty;
|
||||||
|
string reason = obj.TryGetProperty("reason", out var pReason) ? pReason.GetString() ?? string.Empty : string.Empty;
|
||||||
|
string typeStr = obj.TryGetProperty("type", out var pType) ? pType.GetString() ?? string.Empty : string.Empty;
|
||||||
|
string occurredAtStr = obj.TryGetProperty("occurredAt", out var pOccurredAt) ? pOccurredAt.GetString() ?? string.Empty : string.Empty;
|
||||||
|
|
||||||
|
decimal amount = 0m;
|
||||||
|
if (obj.TryGetProperty("amount", out var pAmount))
|
||||||
|
{
|
||||||
|
if (pAmount.ValueKind == JsonValueKind.Number && pAmount.TryGetDecimal(out var d)) amount = d;
|
||||||
|
else if (pAmount.ValueKind == JsonValueKind.String && decimal.TryParse(pAmount.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds)) amount = ds;
|
||||||
|
}
|
||||||
|
|
||||||
|
decimal balance = 0m;
|
||||||
|
if (obj.TryGetProperty("balance", out var pBalance))
|
||||||
|
{
|
||||||
|
if (pBalance.ValueKind == JsonValueKind.Number && pBalance.TryGetDecimal(out var d2)) balance = d2;
|
||||||
|
else if (pBalance.ValueKind == JsonValueKind.String && decimal.TryParse(pBalance.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds2)) balance = ds2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(card) || string.IsNullOrWhiteSpace(reason))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var occurredAt = (DateTime?)null;
|
||||||
|
if(DateTime.TryParse(occurredAtStr, out var occurredAtValue))
|
||||||
|
{
|
||||||
|
occurredAt = occurredAtValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = DetermineTransactionType(typeStr, reason, amount);
|
||||||
|
return (card, reason, amount, balance, type, occurredAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断交易类型
|
||||||
|
/// </summary>
|
||||||
|
protected TransactionType DetermineTransactionType(string typeStr, string reason, decimal amount)
|
||||||
|
{
|
||||||
|
// 优先使用明确的类型字符串
|
||||||
|
if (!string.IsNullOrWhiteSpace(typeStr))
|
||||||
|
{
|
||||||
|
if (typeStr.Contains("收入") || typeStr.Contains("income") || typeStr.Equals("收", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return TransactionType.Income;
|
||||||
|
if (typeStr.Contains("支出") || typeStr.Contains("expense") || typeStr.Equals("支", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return TransactionType.Expense;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据交易原因中的关键词判断
|
||||||
|
var lowerReason = reason.ToLower();
|
||||||
|
|
||||||
|
// 收入关键词
|
||||||
|
string[] incomeKeywords = { "工资", "奖金", "退款", "返现", "收入", "转入", "存入", "利息", "分红" };
|
||||||
|
if (incomeKeywords.Any(k => lowerReason.Contains(k)))
|
||||||
|
return TransactionType.Income;
|
||||||
|
|
||||||
|
// 支出关键词
|
||||||
|
string[] expenseKeywords = { "消费", "支付", "购买", "转出", "取款", "支出", "扣款", "缴费" };
|
||||||
|
if (expenseKeywords.Any(k => lowerReason.Contains(k)))
|
||||||
|
return TransactionType.Expense;
|
||||||
|
|
||||||
|
// 根据金额正负判断(如果金额为负数,可能是支出)
|
||||||
|
if (amount < 0)
|
||||||
|
return TransactionType.Expense;
|
||||||
|
if (amount > 0)
|
||||||
|
return TransactionType.Income;
|
||||||
|
|
||||||
|
// 默认为支出
|
||||||
|
return TransactionType.Expense;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Service/GlobalUsings.cs
Normal file
13
Service/GlobalUsings.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
global using Repository;
|
||||||
|
global using Microsoft.Extensions.DependencyInjection;
|
||||||
|
global using Microsoft.Extensions.Logging;
|
||||||
|
global using System.Text.RegularExpressions;
|
||||||
|
global using Microsoft.Extensions.Options;
|
||||||
|
global using System.Globalization;
|
||||||
|
global using System.Text;
|
||||||
|
global using System.Text.Json;
|
||||||
|
global using Entity;
|
||||||
|
global using FreeSql;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Security.Cryptography;
|
||||||
|
global using Service.AppSettingModel;
|
||||||
499
Service/ImportService.cs
Normal file
499
Service/ImportService.cs
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
using CsvHelper;
|
||||||
|
using CsvHelper.Configuration;
|
||||||
|
using OfficeOpenXml;
|
||||||
|
|
||||||
|
namespace Service;
|
||||||
|
|
||||||
|
public interface IImportService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 导入支付宝账单
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool ok, string message)> ImportAlipayAsync(MemoryStream file, string fileExtension);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 导入微信账单
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool ok, string message)> ImportWeChatAsync(MemoryStream file, string fileExtension);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ImportService(
|
||||||
|
ILogger<ImportService> logger,
|
||||||
|
ITransactionRecordRepository transactionRecordRepository
|
||||||
|
) : IImportService
|
||||||
|
{
|
||||||
|
public async Task<(bool ok, string message)> ImportAlipayAsync(MemoryStream file, string fileExtension)
|
||||||
|
{
|
||||||
|
var content = await ParseAsync(file, fileExtension);
|
||||||
|
logger.LogInformation("转换后的支付宝账单数据行数: {RowCount}", content.Length);
|
||||||
|
|
||||||
|
if (content.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("支付宝账单文件解析后无数据行");
|
||||||
|
return (false, "支付宝账单文件解析后无数据行");
|
||||||
|
}
|
||||||
|
|
||||||
|
var addTransactionRecords = new List<TransactionRecord>();
|
||||||
|
var updateTransactionRecords = new List<TransactionRecord>();
|
||||||
|
foreach (var row in content)
|
||||||
|
{
|
||||||
|
var importNo = row.ContainsKey("交易号") ? row["交易号"] : string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(importNo))
|
||||||
|
{
|
||||||
|
logger.LogWarning("跳过无交易号的记录: {Row}", row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingRecord = await transactionRecordRepository.ExistsByImportNoAsync(importNo, "支付宝");
|
||||||
|
|
||||||
|
if (existingRecord != null)
|
||||||
|
{
|
||||||
|
existingRecord.Reason = GetReason(row);
|
||||||
|
existingRecord.Amount = GetDecimalValue(row, "金额(元)");
|
||||||
|
existingRecord.RefundAmount = GetDecimalValue(row, "成功退款(元)");
|
||||||
|
existingRecord.OccurredAt = GetDateTimeValue(row, "交易创建时间");
|
||||||
|
existingRecord.Type = GetTransactionType(row, "收/支");
|
||||||
|
updateTransactionRecords.Add(existingRecord);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var transactionRecord = new TransactionRecord
|
||||||
|
{
|
||||||
|
Reason = GetReason(row),
|
||||||
|
Amount = GetDecimalValue(row, "金额(元)"),
|
||||||
|
RefundAmount = GetDecimalValue(row, "成功退款(元)"),
|
||||||
|
Balance = 0,
|
||||||
|
OccurredAt = GetDateTimeValue(row, "交易创建时间"),
|
||||||
|
Type = GetTransactionType(row, "收/支"),
|
||||||
|
ImportNo = importNo,
|
||||||
|
ImportFrom = "支付宝"
|
||||||
|
};
|
||||||
|
|
||||||
|
addTransactionRecords.Add(transactionRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addTransactionRecords.Count == 0 && updateTransactionRecords.Count == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未找到可导入或更新的支付宝交易记录");
|
||||||
|
return (false, "未找到可导入或更新的支付宝交易记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = new StringBuilder();
|
||||||
|
if (addTransactionRecords.Count > 0)
|
||||||
|
{
|
||||||
|
if (await transactionRecordRepository.AddRangeAsync(addTransactionRecords))
|
||||||
|
{
|
||||||
|
logger.LogInformation("成功导入支付宝交易记录数: {Count}", addTransactionRecords.Count);
|
||||||
|
message.AppendLine($"成功导入支付宝交易记录数: {addTransactionRecords.Count}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateTransactionRecords.Count > 0)
|
||||||
|
{
|
||||||
|
if (await transactionRecordRepository.UpdateRangeAsync(updateTransactionRecords))
|
||||||
|
{
|
||||||
|
logger.LogInformation("成功更新支付宝交易记录数: {Count}", updateTransactionRecords.Count);
|
||||||
|
message.AppendLine($"成功更新支付宝交易记录数: {updateTransactionRecords.Count}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, message.ToString());
|
||||||
|
|
||||||
|
string GetReason(IDictionary<string, string> row)
|
||||||
|
{
|
||||||
|
var reason = string.Empty;
|
||||||
|
if (row.ContainsKey("交易对方") && !string.IsNullOrWhiteSpace(row["交易对方"]))
|
||||||
|
{
|
||||||
|
reason += row["交易对方"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.ContainsKey("商品名称") && !string.IsNullOrWhiteSpace(row["商品名称"]))
|
||||||
|
{
|
||||||
|
reason += row["商品名称"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
decimal GetDecimalValue(IDictionary<string, string> row, string key)
|
||||||
|
{
|
||||||
|
if (row.ContainsKey(key) && decimal.TryParse(row[key], out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime GetDateTimeValue(IDictionary<string, string> row, string key)
|
||||||
|
{
|
||||||
|
if (!row.ContainsKey(key))
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var format in DateTimeFormats)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParseExact(
|
||||||
|
row[key],
|
||||||
|
format,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None,
|
||||||
|
out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.TryParse(row[key], out var value2))
|
||||||
|
{
|
||||||
|
return value2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionType GetTransactionType(IDictionary<string, string> row, string key)
|
||||||
|
{
|
||||||
|
if (!row.ContainsKey(key))
|
||||||
|
{
|
||||||
|
return TransactionType.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
var typeStr = row[key];
|
||||||
|
|
||||||
|
return typeStr switch
|
||||||
|
{
|
||||||
|
"支出" => TransactionType.Expense,
|
||||||
|
"收入" => TransactionType.Income,
|
||||||
|
_ => TransactionType.None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool ok, string message)> ImportWeChatAsync(MemoryStream file, string fileExtension)
|
||||||
|
{
|
||||||
|
var content = await ParseAsync(file, fileExtension);
|
||||||
|
logger.LogInformation("转换后的微信账单数据行数: {RowCount}", content.Length);
|
||||||
|
|
||||||
|
if (content.Length == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("微信账单文件解析后无数据行");
|
||||||
|
return (false, "微信账单文件解析后无数据行");
|
||||||
|
}
|
||||||
|
|
||||||
|
var addTransactionRecords = new List<TransactionRecord>();
|
||||||
|
var updateTransactionRecords = new List<TransactionRecord>();
|
||||||
|
foreach (var row in content)
|
||||||
|
{
|
||||||
|
var importNo = row.ContainsKey("交易单号") ? row["交易单号"] : string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(importNo))
|
||||||
|
{
|
||||||
|
logger.LogWarning("跳过无交易单号的记录: {Row}", row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingRecord = await transactionRecordRepository.ExistsByImportNoAsync(importNo, "微信");
|
||||||
|
|
||||||
|
if (existingRecord != null)
|
||||||
|
{
|
||||||
|
existingRecord.Reason = GetReason(row);
|
||||||
|
existingRecord.Amount = GetAmountValue(row, "金额(元)");
|
||||||
|
existingRecord.OccurredAt = GetDateTimeValue(row, "交易时间");
|
||||||
|
existingRecord.Type = GetTransactionType(row, "收/支");
|
||||||
|
existingRecord.RefundAmount = GetRefundAmountValue(row);
|
||||||
|
updateTransactionRecords.Add(existingRecord);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var transactionRecord = new TransactionRecord
|
||||||
|
{
|
||||||
|
Reason = GetReason(row),
|
||||||
|
Amount = GetAmountValue(row, "金额(元)"),
|
||||||
|
RefundAmount = GetRefundAmountValue(row),
|
||||||
|
Balance = 0,
|
||||||
|
OccurredAt = GetDateTimeValue(row, "交易时间"),
|
||||||
|
Type = GetTransactionType(row, "收/支"),
|
||||||
|
ImportNo = importNo,
|
||||||
|
ImportFrom = "微信"
|
||||||
|
};
|
||||||
|
|
||||||
|
addTransactionRecords.Add(transactionRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addTransactionRecords.Count == 0 && updateTransactionRecords.Count == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("未找到可导入或更新的微信交易记录");
|
||||||
|
return (false, "未找到可导入或更新的微信交易记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = new StringBuilder();
|
||||||
|
if (addTransactionRecords.Count > 0)
|
||||||
|
{
|
||||||
|
if (await transactionRecordRepository.AddRangeAsync(addTransactionRecords))
|
||||||
|
{
|
||||||
|
logger.LogInformation("成功导入微信交易记录数: {Count}", addTransactionRecords.Count);
|
||||||
|
message.AppendLine($"成功导入微信交易记录数: {addTransactionRecords.Count}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateTransactionRecords.Count > 0)
|
||||||
|
{
|
||||||
|
if (await transactionRecordRepository.UpdateRangeAsync(updateTransactionRecords))
|
||||||
|
{
|
||||||
|
logger.LogInformation("成功更新微信交易记录数: {Count}", updateTransactionRecords.Count);
|
||||||
|
message.AppendLine($"成功更新微信交易记录数: {updateTransactionRecords.Count}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, message.ToString());
|
||||||
|
|
||||||
|
string GetReason(IDictionary<string, string> row)
|
||||||
|
{
|
||||||
|
var reason = string.Empty;
|
||||||
|
if (row.ContainsKey("交易类型") && !string.IsNullOrWhiteSpace(row["交易类型"]))
|
||||||
|
{
|
||||||
|
reason += row["交易类型"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.ContainsKey("交易对方") && !string.IsNullOrWhiteSpace(row["交易对方"]))
|
||||||
|
{
|
||||||
|
reason += row["交易对方"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.ContainsKey("商品") && !string.IsNullOrWhiteSpace(row["商品"]))
|
||||||
|
{
|
||||||
|
reason += row["商品"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
decimal GetAmountValue(IDictionary<string, string> row, string key)
|
||||||
|
{
|
||||||
|
if (row.ContainsKey(key) && decimal.TryParse(row[key].TrimStart('¥').TrimStart('¥'), out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime GetDateTimeValue(IDictionary<string, string> row, string key)
|
||||||
|
{
|
||||||
|
if(!row.ContainsKey(key))
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var format in DateTimeFormats)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParseExact(
|
||||||
|
row[key],
|
||||||
|
format,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None,
|
||||||
|
out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.TryParse(row[key], out var value2))
|
||||||
|
{
|
||||||
|
return value2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionType GetTransactionType(IDictionary<string, string> row, string key)
|
||||||
|
{
|
||||||
|
if (!row.ContainsKey(key))
|
||||||
|
{
|
||||||
|
return TransactionType.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
var typeStr = row[key];
|
||||||
|
|
||||||
|
return typeStr switch
|
||||||
|
{
|
||||||
|
"支出" => TransactionType.Expense,
|
||||||
|
"收入" => TransactionType.Income,
|
||||||
|
_ => TransactionType.None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
decimal GetRefundAmountValue(IDictionary<string, string> row)
|
||||||
|
{
|
||||||
|
if (!row.ContainsKey("当前状态"))
|
||||||
|
{
|
||||||
|
return 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = row["当前状态"];
|
||||||
|
|
||||||
|
if (!status.Contains("退款"))
|
||||||
|
{
|
||||||
|
return 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用正则表达式提取退款金额
|
||||||
|
var regex = new System.Text.RegularExpressions.Regex(@"¥(-?\d+(\.\d+)?)");
|
||||||
|
var match = regex.Match(status);
|
||||||
|
if (match.Success && decimal.TryParse(match.Groups[1].Value, out var refundAmount))
|
||||||
|
{
|
||||||
|
return refundAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IDictionary<string, string>[]> ParseAsync(MemoryStream file, string fileExtension)
|
||||||
|
{
|
||||||
|
if (fileExtension == ".csv")
|
||||||
|
{
|
||||||
|
return await ParseCsvAsync(file);
|
||||||
|
}
|
||||||
|
else if (fileExtension == ".xlsx" || fileExtension == ".xls")
|
||||||
|
{
|
||||||
|
return await ParseExcelAsync(file);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new NotSupportedException("不支持的文件格式");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IDictionary<string, string>[]> ParseCsvAsync(MemoryStream file)
|
||||||
|
{
|
||||||
|
file.Position = 0;
|
||||||
|
using var reader = new StreamReader(file, Encoding.UTF8);
|
||||||
|
|
||||||
|
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||||
|
{
|
||||||
|
HasHeaderRecord = true,
|
||||||
|
TrimOptions = TrimOptions.Trim
|
||||||
|
};
|
||||||
|
|
||||||
|
using var csv = new CsvReader(reader, config);
|
||||||
|
|
||||||
|
// 读取表头
|
||||||
|
await csv.ReadAsync();
|
||||||
|
csv.ReadHeader();
|
||||||
|
var headers = csv.HeaderRecord;
|
||||||
|
|
||||||
|
if (headers == null || headers.Length == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<IDictionary<string, string>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<IDictionary<string, string>>();
|
||||||
|
|
||||||
|
// 读取数据行
|
||||||
|
while (await csv.ReadAsync())
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, string>();
|
||||||
|
foreach (var header in headers)
|
||||||
|
{
|
||||||
|
row[header] = csv.GetField(header) ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IDictionary<string, string>[]> ParseExcelAsync(MemoryStream file)
|
||||||
|
{
|
||||||
|
file.Position = 0;
|
||||||
|
|
||||||
|
// 设置 EPPlus 许可证上下文
|
||||||
|
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
|
||||||
|
|
||||||
|
using var package = new ExcelPackage(file);
|
||||||
|
var worksheet = package.Workbook.Worksheets.FirstOrDefault();
|
||||||
|
|
||||||
|
if (worksheet == null || worksheet.Dimension == null)
|
||||||
|
{
|
||||||
|
return Array.Empty<IDictionary<string, string>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var rowCount = worksheet.Dimension.End.Row;
|
||||||
|
var colCount = worksheet.Dimension.End.Column;
|
||||||
|
|
||||||
|
if (rowCount < 2)
|
||||||
|
{
|
||||||
|
return Array.Empty<IDictionary<string, string>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取表头(第一行)
|
||||||
|
var headers = new List<string>();
|
||||||
|
for (int col = 1; col <= colCount; col++)
|
||||||
|
{
|
||||||
|
var header = worksheet.Cells[1, col].Text?.Trim() ?? string.Empty;
|
||||||
|
headers.Add(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<IDictionary<string, string>>();
|
||||||
|
|
||||||
|
// 读取数据行(从第二行开始)
|
||||||
|
for (int row = 2; row <= rowCount; row++)
|
||||||
|
{
|
||||||
|
var rowData = new Dictionary<string, string>();
|
||||||
|
for (int col = 1; col <= colCount; col++)
|
||||||
|
{
|
||||||
|
var header = headers[col - 1];
|
||||||
|
var value = worksheet.Cells[row, col].Text?.Trim() ?? string.Empty;
|
||||||
|
rowData[header] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(rowData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Task.FromResult(result.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] DateTimeFormats =
|
||||||
|
[
|
||||||
|
"yyyy-MM-dd",
|
||||||
|
"yyyy-MM-dd HH",
|
||||||
|
"yyyy-MM-dd HH:mm",
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
"yyyy-M-d",
|
||||||
|
"yyyy-M-d HH:mm",
|
||||||
|
"yyyy-M-d HH:mm:ss",
|
||||||
|
"yyyy/MM/dd",
|
||||||
|
"yyyy/MM/dd HH",
|
||||||
|
"yyyy/MM/dd HH:mm",
|
||||||
|
"yyyy/MM/dd HH:mm:ss",
|
||||||
|
"yyyy/M/d",
|
||||||
|
"yyyy/M/d HH:mm",
|
||||||
|
"yyyy/M/d HH:mm:ss",
|
||||||
|
"MM/dd/yyyy",
|
||||||
|
"MM/dd/yyyy HH",
|
||||||
|
"MM/dd/yyyy HH:mm",
|
||||||
|
"MM/dd/yyyy HH:mm:ss",
|
||||||
|
"M/d/yyyy",
|
||||||
|
"M/d/yyyy HH:mm",
|
||||||
|
"M/d/yyyy HH:mm:ss",
|
||||||
|
"MM/dd/yy",
|
||||||
|
"M/d/yy H:mm",
|
||||||
|
"MM/dd/yy HH",
|
||||||
|
"MM/dd/yy HH:mm",
|
||||||
|
"MM/dd/yy HH:mm:ss",
|
||||||
|
"M/d/yy",
|
||||||
|
"M/d/yy HH",
|
||||||
|
"M/d/yy HH:mm",
|
||||||
|
"M/d/yy HH:mm:ss",
|
||||||
|
"yyyyMMdd",
|
||||||
|
"yyyyMMddHH",
|
||||||
|
"yyyyMMddHHmm",
|
||||||
|
"yyyyMMddHHmmss",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
71
Service/OpenAiService.cs
Normal file
71
Service/OpenAiService.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace Service;
|
||||||
|
|
||||||
|
public interface IOpenAiService
|
||||||
|
{
|
||||||
|
Task<string?> ChatAsync(string systemPrompt, string userPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OpenAiService(
|
||||||
|
IOptions<AISettings> aiSettings,
|
||||||
|
ILogger<OpenAiService> logger
|
||||||
|
) : IOpenAiService
|
||||||
|
{
|
||||||
|
public async Task<string?> ChatAsync(string systemPrompt, string userPrompt)
|
||||||
|
{
|
||||||
|
var cfg = aiSettings.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
||||||
|
string.IsNullOrWhiteSpace(cfg.Key) ||
|
||||||
|
string.IsNullOrWhiteSpace(cfg.Model))
|
||||||
|
{
|
||||||
|
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var http = new HttpClient();
|
||||||
|
http.Timeout = TimeSpan.FromSeconds(15);
|
||||||
|
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
||||||
|
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
model = cfg.Model,
|
||||||
|
temperature = 0,
|
||||||
|
messages = new object[]
|
||||||
|
{
|
||||||
|
new { role = "system", content = systemPrompt },
|
||||||
|
new { role = "user", content = userPrompt }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
||||||
|
var json = JsonSerializer.Serialize(payload);
|
||||||
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var resp = await http.PostAsync(url, content);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var err = await resp.Content.ReadAsStringAsync();
|
||||||
|
throw new InvalidOperationException($"AI接口调用失败: {(int)resp.StatusCode} {resp.ReasonPhrase}, {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var respText = await resp.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(respText);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var contentText = root.GetProperty("choices")[0]
|
||||||
|
.GetProperty("message")
|
||||||
|
.GetProperty("content")
|
||||||
|
.GetString();
|
||||||
|
|
||||||
|
return contentText;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "AI 调用失败");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Service/Service.csproj
Normal file
21
Service/Service.csproj
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Repository\Repository.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MailKit" />
|
||||||
|
<PackageReference Include="MimeKit" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
<PackageReference Include="Serilog" />
|
||||||
|
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||||
|
<PackageReference Include="CsvHelper" />
|
||||||
|
<PackageReference Include="EPPlus" />
|
||||||
|
<PackageReference Include="HtmlAgilityPack" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
8
Web/.editorconfig
Normal file
8
Web/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
2
Web/.env.development
Normal file
2
Web/.env.development
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
VITE_API_BASE_URL=http://localhost:5071/api
|
||||||
2
Web/.env.production
Normal file
2
Web/.env.production
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
1
Web/.gitattributes
vendored
Normal file
1
Web/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
402
Web/.gitignore
vendored
Normal file
402
Web/.gitignore
vendored
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET Core
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||||
|
*.vbp
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
# tools/**
|
||||||
|
# !tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
.localhistory/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# JetBrains Rider
|
||||||
|
*.sln.iml
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.idea/
|
||||||
6
Web/.prettierrc.json
Normal file
6
Web/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
8
Web/.vscode/extensions.json
vendored
Normal file
8
Web/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
13
Web/.vscode/settings.json
vendored
Normal file
13
Web/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"explorer.fileNesting.enabled": true,
|
||||||
|
"explorer.fileNesting.patterns": {
|
||||||
|
"tsconfig.json": "tsconfig.*.json, env.d.ts",
|
||||||
|
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
|
||||||
|
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
|
||||||
|
},
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit"
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
}
|
||||||
44
Web/README.md
Normal file
44
Web/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# email-bill
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Recommended Browser Setup
|
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||||
|
- Firefox:
|
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint with [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
1
Web/dist/assets/CalendarView-By4eHUMb.js
vendored
Normal file
1
Web/dist/assets/CalendarView-By4eHUMb.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{_ as e}from"./_plugin-vue_export-helper-DlAUqK2U.js";import{c as r,f as c}from"./index-CoRZCnfa.js";const n={};function o(t,a){return c(),r("div",null,"111")}const f=e(n,[["render",o]]);export{f as default};
|
||||||
1
Web/dist/assets/EmailRecord-Hpcg_SM6.css
vendored
Normal file
1
Web/dist/assets/EmailRecord-Hpcg_SM6.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.page-container[data-v-a7afa8a2]{min-height:100vh;background-color:#f5f5f5}@media(prefers-color-scheme:dark){.page-container[data-v-a7afa8a2]{background-color:#1a1a1a}}.refresh-wrapper[data-v-a7afa8a2]{min-height:calc(100vh - 46px)}[data-v-a7afa8a2] .van-cell-group--inset{margin:10px 16px;background-color:#fff;box-shadow:0 2px 8px #00000014}@media(prefers-color-scheme:dark){[data-v-a7afa8a2] .van-cell-group--inset{background-color:#2c2c2c;box-shadow:0 2px 8px #0000004d}}[data-v-a7afa8a2] .van-cell{background-color:#fff;border-bottom:1px solid #f0f0f0}@media(prefers-color-scheme:dark){[data-v-a7afa8a2] .van-cell{background-color:#2c2c2c;border-bottom:1px solid #3a3a3a}}[data-v-a7afa8a2] .van-cell:last-child{border-bottom:none}.detail-popup[data-v-a7afa8a2]{padding:16px;height:100%;overflow-y:auto;background-color:#f5f5f5}@media(prefers-color-scheme:dark){.detail-popup[data-v-a7afa8a2]{background-color:#1a1a1a}}.detail-popup[data-v-a7afa8a2] .van-cell-group--inset{background-color:#fff;box-shadow:0 2px 8px #00000014}@media(prefers-color-scheme:dark){.detail-popup[data-v-a7afa8a2] .van-cell-group--inset{background-color:#2c2c2c;box-shadow:0 2px 8px #0000004d}}.detail-header[data-v-a7afa8a2]{margin-bottom:16px}.detail-header h3[data-v-a7afa8a2]{margin:0;font-size:18px;font-weight:700;word-break:break-word}.detail-header p[data-v-a7afa8a2]{margin:0;font-size:14px;color:#969799;font-weight:400}[data-v-a7afa8a2] .van-nav-bar{background-color:transparent}[data-v-a7afa8a2] .van-field__control,[data-v-a7afa8a2] .van-field__value{word-break:break-all;white-space:normal}.email-date[data-v-a7afa8a2]{font-size:12px;color:#969799;padding-right:10px}.email-content[data-v-a7afa8a2]{margin-top:16px}.email-content h4[data-v-a7afa8a2]{margin:0 0 12px;font-size:16px;font-weight:700}.content-body[data-v-a7afa8a2]{padding:12px;border-radius:8px;white-space:pre-wrap;word-break:break-word;font-size:14px;line-height:1.6;max-height:350px;overflow-y:auto;background-color:#fff;border:1px solid #e5e5e5;margin:0 20px}@media(prefers-color-scheme:dark){.content-body[data-v-a7afa8a2]{background-color:#2c2c2c;border:1px solid #3a3a3a}}.delete-button[data-v-a7afa8a2]{height:100%}
|
||||||
1
Web/dist/assets/EmailRecord-ooSXYRv8.js
vendored
Normal file
1
Web/dist/assets/EmailRecord-ooSXYRv8.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
Web/dist/assets/SettingView-BhjwUKGN.css
vendored
Normal file
1
Web/dist/assets/SettingView-BhjwUKGN.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[data-v-3c89ac7f] .van-nav-bar{background-color:transparent}[data-v-3c89ac7f] body{background-color:#f5f5f5}@media(prefers-color-scheme:dark){[data-v-3c89ac7f] body{background-color:#1a1a1a}}[data-v-3c89ac7f] .van-cell-group--inset{background-color:#fff;box-shadow:0 2px 8px #00000014}@media(prefers-color-scheme:dark){[data-v-3c89ac7f] .van-cell-group--inset{background-color:#2c2c2c;box-shadow:0 2px 8px #0000004d}}.detail-header[data-v-3c89ac7f]{padding:16px 16px 5px}.detail-header p[data-v-3c89ac7f]{margin:0;font-size:14px;color:#969799;font-weight:400}
|
||||||
1
Web/dist/assets/SettingView-DpsY2gE2.js
vendored
Normal file
1
Web/dist/assets/SettingView-DpsY2gE2.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{a as g}from"./index-B9ygI19o.js";import{s as l,r as _,c as k,b as r,d as c,e as d,w as y,v as x,x as h,y as w,f as C}from"./index-CoRZCnfa.js";import{_ as b}from"./_plugin-vue_export-helper-DlAUqK2U.js";const T=(m,p)=>{const n=new FormData;return n.append("file",m),n.append("type",p),g({url:"/api/BillImport/UploadFile",method:"post",data:n,headers:{"Content-Type":"multipart/form-data"},timeout:6e4}).then(t=>{const{data:s}=t;return s.success===!1?(l(s.message||"上传失败"),Promise.reject(new Error(s.message||"上传失败"))):s}).catch(t=>{if(console.error("上传错误:",t),t.response){const{status:s,data:o}=t.response;let e="上传失败";switch(s){case 400:e=o?.message||"请求参数错误";break;case 401:e="未授权,请先登录";break;case 403:e="没有权限";break;case 413:e="文件过大";break;case 500:e="服务器错误";break}return l(e),Promise.reject(new Error(e))}return l("网络错误,请检查网络连接"),Promise.reject(t)})},B={__name:"SettingView",setup(m){const p=_(null),n=_(""),t=o=>{n.value=o,p.value?.click()},s=async o=>{const e=o.target.files?.[0];if(!e)return;if(!["text/csv","application/vnd.ms-excel","application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"].includes(e.type)){l("请选择 CSV 或 Excel 文件");return}const i=10*1024*1024;if(e.size>i){l("文件大小不能超过 10MB");return}try{x({message:"上传中...",forbidClick:!0,duration:0});const a=n.value==="Alipay"?"支付宝":"微信",{success:u,message:v}=await T(e,n.value);if(!u){l(v||`${a}账单导入失败`);return}h(v||`${a}账单导入成功`)}catch(a){console.error("上传失败:",a),l("上传失败: "+(a.message||"未知错误"))}finally{w(),o.target.value=""}};return(o,e)=>{const f=d("van-nav-bar"),i=d("van-cell"),a=d("van-cell-group");return C(),k("div",null,[r(f,{title:"设置"}),e[2]||(e[2]=c("div",{class:"detail-header"},[c("p",null,"账单导入")],-1)),r(a,{inset:""},{default:y(()=>[r(i,{title:"从支付宝导入","is-link":"",onClick:e[0]||(e[0]=u=>t("Alipay"))}),r(i,{title:"从微信导入","is-link":"",onClick:e[1]||(e[1]=u=>t("WeChat"))})]),_:1}),c("input",{ref_key:"fileInputRef",ref:p,type:"file",accept:".csv,.xlsx,.xls",style:{display:"none"},onChange:s},null,544),e[3]||(e[3]=c("div",{class:"detail-header"},[c("p",null,"账单处理")],-1)),r(a,{inset:""},{default:y(()=>[r(i,{title:"智能分类","is-link":""})]),_:1})])}}},$=b(B,[["__scopeId","data-v-3c89ac7f"]]);export{$ as default};
|
||||||
1
Web/dist/assets/TransactionsRecord-COA69EP3.js
vendored
Normal file
1
Web/dist/assets/TransactionsRecord-COA69EP3.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
Web/dist/assets/TransactionsRecord-D6q5JXmJ.css
vendored
Normal file
1
Web/dist/assets/TransactionsRecord-D6q5JXmJ.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.page-container[data-v-bdc7ecd0]{min-height:100vh;background-color:#f5f5f5}@media(prefers-color-scheme:dark){.page-container[data-v-bdc7ecd0]{background-color:#1a1a1a}}.refresh-wrapper[data-v-bdc7ecd0]{min-height:calc(100vh - 46px)}[data-v-bdc7ecd0] .van-cell-group--inset{margin:10px 16px;background-color:#fff;box-shadow:0 2px 8px #00000014}@media(prefers-color-scheme:dark){[data-v-bdc7ecd0] .van-cell-group--inset{background-color:#2c2c2c;box-shadow:0 2px 8px #0000004d}}[data-v-bdc7ecd0] .van-cell{background-color:#fff;border-bottom:1px solid #f0f0f0}@media(prefers-color-scheme:dark){[data-v-bdc7ecd0] .van-cell{background-color:#2c2c2c;border-bottom:1px solid #3a3a3a}}[data-v-bdc7ecd0] .van-cell:last-child{border-bottom:none}.detail-popup[data-v-bdc7ecd0]{padding:16px;height:100%;overflow-y:auto;background-color:#f5f5f5}@media(prefers-color-scheme:dark){.detail-popup[data-v-bdc7ecd0]{background-color:#1a1a1a}}.detail-popup[data-v-bdc7ecd0] .van-cell-group--inset{background-color:#fff;box-shadow:0 2px 8px #00000014}@media(prefers-color-scheme:dark){.detail-popup[data-v-bdc7ecd0] .van-cell-group--inset{background-color:#2c2c2c;box-shadow:0 2px 8px #0000004d}}.detail-header[data-v-bdc7ecd0]{margin-bottom:16px}.detail-header h3[data-v-bdc7ecd0]{margin:0;font-size:18px;font-weight:700;word-break:break-word}.detail-header p[data-v-bdc7ecd0]{margin:0;font-size:14px;color:#969799;font-weight:400}[data-v-bdc7ecd0] .van-nav-bar{background-color:transparent}[data-v-bdc7ecd0] .van-field__control,[data-v-bdc7ecd0] .van-field__value{word-break:break-all;white-space:normal}.floating-search[data-v-bdc7ecd0]{position:fixed;bottom:50px;left:0;right:0;z-index:999;padding:8px 16px;background:transparent;pointer-events:none}.floating-search[data-v-bdc7ecd0] .van-search{pointer-events:auto;box-shadow:0 2px 12px #00000026;border-radius:20px;border:none}.transaction-card[data-v-bdc7ecd0]{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;cursor:pointer;transition:background-color .3s}.card-left[data-v-bdc7ecd0]{flex:1;min-width:0;padding-right:12px}.card-right[data-v-bdc7ecd0]{display:flex;align-items:center;gap:12px;flex-shrink:0}.transaction-title[data-v-bdc7ecd0]{display:flex;align-items:center;font-weight:700;margin-bottom:8px;gap:8px}.reason[data-v-bdc7ecd0]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.transaction-info[data-v-bdc7ecd0]{font-size:12px;color:#969799;line-height:1.6}.transaction-amount[data-v-bdc7ecd0]{text-align:right;display:flex;flex-direction:column;align-items:flex-end;min-width:90px}.amount[data-v-bdc7ecd0]{font-size:18px;font-weight:700;margin-bottom:4px;white-space:nowrap}.amount.expense[data-v-bdc7ecd0]{color:#ee0a24}.amount.income[data-v-bdc7ecd0]{color:#07c160}.amount.neutral[data-v-bdc7ecd0]{color:#646566}.balance[data-v-bdc7ecd0]{font-size:12px;color:#969799;white-space:nowrap}.picker-toolbar[data-v-bdc7ecd0]{display:flex;width:100%;align-items:center;padding:5px 10px;border-bottom:1px solid #ebedf0}.toolbar-cancel[data-v-bdc7ecd0]{margin-right:auto}.toolbar-confirm[data-v-bdc7ecd0]{margin-left:auto}.delete-button[data-v-bdc7ecd0]{height:100%}
|
||||||
1
Web/dist/assets/_plugin-vue_export-helper-DlAUqK2U.js
vendored
Normal file
1
Web/dist/assets/_plugin-vue_export-helper-DlAUqK2U.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
const s=(t,r)=>{const o=t.__vccOpts||t;for(const[c,e]of r)o[c]=e;return o};export{s as _};
|
||||||
6
Web/dist/assets/index-B9ygI19o.js
vendored
Normal file
6
Web/dist/assets/index-B9ygI19o.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
Web/dist/assets/index-CoRZCnfa.js
vendored
Normal file
6
Web/dist/assets/index-CoRZCnfa.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
Web/dist/assets/index-JYOJmkEG.css
vendored
Normal file
1
Web/dist/assets/index-JYOJmkEG.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
Web/dist/assets/request-CDAs_I05.js
vendored
Normal file
1
Web/dist/assets/request-CDAs_I05.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import{k as r,l as c,m as i,u,b as m,p,q as f,s as a}from"./index-CoRZCnfa.js";import{a as d}from"./index-B9ygI19o.js";let n;const g={title:"",width:"",theme:null,message:"",overlay:!0,callback:null,teleport:"body",className:"",allowHtml:!1,lockScroll:!0,transition:void 0,beforeClose:null,overlayClass:"",overlayStyle:void 0,messageAlign:"",cancelButtonText:"",cancelButtonColor:null,cancelButtonDisabled:!1,confirmButtonText:"",confirmButtonColor:null,confirmButtonDisabled:!1,showConfirmButton:!0,showCancelButton:!1,closeOnPopstate:!0,closeOnClickOverlay:!1,destroyOnClose:!1};let b=r({},g);function w(){({instance:n}=i({setup(){const{state:s,toggle:o}=u();return()=>m(f,p(s,{"onUpdate:show":o}),null)}}))}function C(e){return c?new Promise((s,o)=>{n||w(),n.open(r({},b,e,{callback:t=>{(t==="confirm"?s:o)(t)}}))}):Promise.resolve(void 0)}const B=e=>C(r({showCancelButton:!0},e)),l=d.create({baseURL:"/api",timeout:3e4,headers:{"Content-Type":"application/json"}});l.interceptors.request.use(e=>e,e=>(console.error("请求错误:",e),Promise.reject(e)));l.interceptors.response.use(e=>{const{data:s}=e;return s.success===!1?(a(s.message||"请求失败"),Promise.reject(new Error(s.message||"请求失败"))):s},e=>{if(console.error("响应错误:",e),e.response){const{status:s,data:o}=e.response;let t="请求失败";switch(s){case 400:t=o?.message||"请求参数错误";break;case 401:t="未授权,请重新登录";break;case 403:t="拒绝访问";break;case 404:t="请求的资源不存在";break;case 500:t="服务器内部错误";break;default:t=o?.message||`请求失败 (${s})`}a(t)}else e.request?a("网络连接失败,请检查网络"):a(e.message||"请求失败");return Promise.reject(e)});export{l as r,B as s};
|
||||||
BIN
Web/dist/favicon.ico
vendored
Normal file
BIN
Web/dist/favicon.ico
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
14
Web/dist/index.html
vendored
Normal file
14
Web/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vite App</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-CoRZCnfa.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-JYOJmkEG.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
Web/eslint.config.js
Normal file
26
Web/eslint.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
import globals from 'globals'
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{js,mjs,jsx,vue}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||||
|
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
js.configs.recommended,
|
||||||
|
...pluginVue.configs['flat/essential'],
|
||||||
|
skipFormatting,
|
||||||
|
])
|
||||||
13
Web/index.html
Normal file
13
Web/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
Web/jsconfig.json
Normal file
8
Web/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
34
Web/package.json
Normal file
34
Web/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "email-bill",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --fix --cache",
|
||||||
|
"format": "prettier --write --experimental-cli src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vant": "^4.9.22",
|
||||||
|
"vue": "^3.5.25",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-vue": "~10.5.1",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
2738
Web/pnpm-lock.yaml
generated
Normal file
2738
Web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
Web/pnpm-workspace.yaml
Normal file
2
Web/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
BIN
Web/public/favicon.ico
Normal file
BIN
Web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
58
Web/src/App.vue
Normal file
58
Web/src/App.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<van-config-provider :theme="theme">
|
||||||
|
<RouterView />
|
||||||
|
<van-tabbar v-model="active">
|
||||||
|
<van-tabbar-item icon="notes-o" to="/calendar">
|
||||||
|
日历
|
||||||
|
</van-tabbar-item>
|
||||||
|
<van-tabbar-item icon="balance-list-o" to="/" @click="handleTabClick('/')">
|
||||||
|
账单
|
||||||
|
</van-tabbar-item>
|
||||||
|
<van-tabbar-item icon="records-o" to="/email" @click="handleTabClick('/email')">
|
||||||
|
邮件
|
||||||
|
</van-tabbar-item>
|
||||||
|
<van-tabbar-item icon="setting-o" to="/setting">
|
||||||
|
设置
|
||||||
|
</van-tabbar-item>
|
||||||
|
</van-tabbar>
|
||||||
|
</van-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const active = ref(0)
|
||||||
|
const theme = ref('light')
|
||||||
|
|
||||||
|
// 检测系统深色模式
|
||||||
|
const updateTheme = () => {
|
||||||
|
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
theme.value = isDark ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听系统主题变化
|
||||||
|
let mediaQuery
|
||||||
|
onMounted(() => {
|
||||||
|
updateTheme()
|
||||||
|
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
mediaQuery.addEventListener('change', updateTheme)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (mediaQuery) {
|
||||||
|
mediaQuery.removeEventListener('change', updateTheme)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理tab点击,如果点击当前页面则滚动到顶部
|
||||||
|
const handleTabClick = (path) => {
|
||||||
|
if (route.path === path) {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
68
Web/src/api/billImport.js
Normal file
68
Web/src/api/billImport.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账单导入相关 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传账单文件
|
||||||
|
* @param {File} file - 要上传的文件
|
||||||
|
* @param {string} type - 账单类型 ('Alipay' | 'WeChat')
|
||||||
|
* @returns {Promise<{success: boolean, message: string, data: any}>}
|
||||||
|
*/
|
||||||
|
export const uploadBillFile = (file, type) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('type', type)
|
||||||
|
|
||||||
|
return axios({
|
||||||
|
url: `${import.meta.env.VITE_API_BASE_URL}/BillImport/UploadFile`,
|
||||||
|
method: 'post',
|
||||||
|
data: formData,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
},
|
||||||
|
timeout: 60000 // 文件上传增加超时时间
|
||||||
|
}).then(response => {
|
||||||
|
const { data } = response
|
||||||
|
|
||||||
|
if (data.success === false) {
|
||||||
|
showToast(data.message || '上传失败')
|
||||||
|
return Promise.reject(new Error(data.message || '上传失败'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('上传错误:', error)
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
const { status, data } = error.response
|
||||||
|
let message = '上传失败'
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 400:
|
||||||
|
message = data?.message || '请求参数错误'
|
||||||
|
break
|
||||||
|
case 401:
|
||||||
|
message = '未授权,请先登录'
|
||||||
|
break
|
||||||
|
case 403:
|
||||||
|
message = '没有权限'
|
||||||
|
break
|
||||||
|
case 413:
|
||||||
|
message = '文件过大'
|
||||||
|
break
|
||||||
|
case 500:
|
||||||
|
message = '服务器错误'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(message)
|
||||||
|
return Promise.reject(new Error(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('网络错误,请检查网络连接')
|
||||||
|
return Promise.reject(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
80
Web/src/api/emailRecord.js
Normal file
80
Web/src/api/emailRecord.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮件记录相关 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取邮件列表(分页)
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {number} [params.latestId] - 最后一条记录的ID,用于游标分页
|
||||||
|
* @returns {Promise<{success: boolean, data: Array, total: number, lastId: number}>}
|
||||||
|
*/
|
||||||
|
export const getEmailList = (params = {}) => {
|
||||||
|
return request({
|
||||||
|
url: '/EmailMessage/GetList',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取邮件详情
|
||||||
|
* @param {number} id - 邮件ID
|
||||||
|
* @returns {Promise<{success: boolean, data: Object}>}
|
||||||
|
*/
|
||||||
|
export const getEmailDetail = (id) => {
|
||||||
|
return request({
|
||||||
|
url: `/EmailMessage/GetById/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除邮件
|
||||||
|
* @param {number} id - 邮件ID
|
||||||
|
* @returns {Promise<{success: boolean}>}
|
||||||
|
*/
|
||||||
|
export const deleteEmail = (id) => {
|
||||||
|
return request({
|
||||||
|
url: `/EmailMessage/DeleteById`,
|
||||||
|
method: 'post',
|
||||||
|
params: { id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新分析邮件并刷新交易记录
|
||||||
|
* @param {number} id - 邮件ID
|
||||||
|
* @returns {Promise<{success: boolean}>}
|
||||||
|
*/
|
||||||
|
export const refreshTransactionRecords = (id) => {
|
||||||
|
return request({
|
||||||
|
url: `/EmailMessage/RefreshTransactionRecords`,
|
||||||
|
method: 'post',
|
||||||
|
params: { id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 立即同步邮件
|
||||||
|
* @returns {Promise<{success: boolean, message: string}>}
|
||||||
|
*/
|
||||||
|
export const syncEmails = () => {
|
||||||
|
return request({
|
||||||
|
url: `/EmailMessage/SyncEmails`,
|
||||||
|
method: 'post'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取邮件关联的交易记录列表
|
||||||
|
* @param {number} emailId - 邮件ID
|
||||||
|
* @returns {Promise<{success: boolean, data: Array}>}
|
||||||
|
*/
|
||||||
|
export const getEmailTransactions = (emailId) => {
|
||||||
|
return request({
|
||||||
|
url: `/TransactionRecord/GetByEmailId/${emailId}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
81
Web/src/api/request.js
Normal file
81
Web/src/api/request.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
request.interceptors.request.use(
|
||||||
|
config => {
|
||||||
|
// 可以在这里添加 token 等认证信息
|
||||||
|
// const token = localStorage.getItem('token')
|
||||||
|
// if (token) {
|
||||||
|
// config.headers.Authorization = `Bearer ${token}`
|
||||||
|
// }
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
console.error('请求错误:', error)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
request.interceptors.response.use(
|
||||||
|
response => {
|
||||||
|
const { data } = response
|
||||||
|
|
||||||
|
// 统一处理业务错误
|
||||||
|
if (data.success === false) {
|
||||||
|
showToast(data.message || '请求失败')
|
||||||
|
return Promise.reject(new Error(data.message || '请求失败'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
console.error('响应错误:', error)
|
||||||
|
|
||||||
|
// 统一处理 HTTP 错误
|
||||||
|
if (error.response) {
|
||||||
|
const { status, data } = error.response
|
||||||
|
let message = '请求失败'
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 400:
|
||||||
|
message = data?.message || '请求参数错误'
|
||||||
|
break
|
||||||
|
case 401:
|
||||||
|
message = '未授权,请重新登录'
|
||||||
|
break
|
||||||
|
case 403:
|
||||||
|
message = '拒绝访问'
|
||||||
|
break
|
||||||
|
case 404:
|
||||||
|
message = '请求的资源不存在'
|
||||||
|
break
|
||||||
|
case 500:
|
||||||
|
message = '服务器内部错误'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
message = data?.message || `请求失败 (${status})`
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(message)
|
||||||
|
} else if (error.request) {
|
||||||
|
showToast('网络连接失败,请检查网络')
|
||||||
|
} else {
|
||||||
|
showToast(error.message || '请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default request
|
||||||
104
Web/src/api/transactionCategory.js
Normal file
104
Web/src/api/transactionCategory.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分类树(支持按类型筛选)
|
||||||
|
* @param {string|null} type - 交易类型(Expense=0/Income=1),null表示获取全部
|
||||||
|
* @returns {Promise<{success: boolean, data: Array}>}
|
||||||
|
*/
|
||||||
|
export const getCategoryTree = (type = null) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionCategory/GetTree',
|
||||||
|
method: 'get',
|
||||||
|
params: type !== null ? { type } : {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取顶级分类列表(按类型)
|
||||||
|
* @param {number} type - 交易类型(Expense=0/Income=1)
|
||||||
|
* @returns {Promise<{success: boolean, data: Array}>}
|
||||||
|
*/
|
||||||
|
export const getTopLevelCategories = (type) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionCategory/GetTopLevel',
|
||||||
|
method: 'get',
|
||||||
|
params: { type }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取子分类列表
|
||||||
|
* @param {number} parentId - 父分类ID
|
||||||
|
* @returns {Promise<{success: boolean, data: Array}>}
|
||||||
|
*/
|
||||||
|
export const getChildCategories = (parentId) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionCategory/GetChildren',
|
||||||
|
method: 'get',
|
||||||
|
params: { parentId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取分类详情
|
||||||
|
* @param {number} id - 分类ID
|
||||||
|
* @returns {Promise<{success: boolean, data: object}>}
|
||||||
|
*/
|
||||||
|
export const getCategoryById = (id) => {
|
||||||
|
return request({
|
||||||
|
url: `/TransactionCategory/GetById/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分类
|
||||||
|
* @param {object} data - 分类数据
|
||||||
|
* @returns {Promise<{success: boolean, data: number}>} 返回新创建的分类ID
|
||||||
|
*/
|
||||||
|
export const createCategory = (data) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionCategory/Create',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新分类
|
||||||
|
* @param {object} data - 分类数据
|
||||||
|
* @returns {Promise<{success: boolean}>}
|
||||||
|
*/
|
||||||
|
export const updateCategory = (data) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionCategory/Update',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除分类
|
||||||
|
* @param {number} id - 分类ID
|
||||||
|
* @returns {Promise<{success: boolean}>}
|
||||||
|
*/
|
||||||
|
export const deleteCategory = (id) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionCategory/Delete',
|
||||||
|
method: 'post',
|
||||||
|
params: { id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量创建分类(用于初始化)
|
||||||
|
* @param {Array} dataList - 分类数据数组
|
||||||
|
* @returns {Promise<{success: boolean, data: number}>} 返回创建的数量
|
||||||
|
*/
|
||||||
|
export const batchCreateCategories = (dataList) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionCategory/BatchCreate',
|
||||||
|
method: 'post',
|
||||||
|
data: dataList
|
||||||
|
})
|
||||||
|
}
|
||||||
102
Web/src/api/transactionRecord.js
Normal file
102
Web/src/api/transactionRecord.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易记录相关 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取交易记录列表(分页)
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {number} [params.latestId] - 最后一条记录的ID,用于游标分页
|
||||||
|
* @param {string} [params.searchKeyword] - 搜索关键词
|
||||||
|
* @returns {Promise<{success: boolean, data: Array, total: number, lastId: number}>}
|
||||||
|
*/
|
||||||
|
export const getTransactionList = (params = {}) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionRecord/GetList',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取交易记录详情
|
||||||
|
* @param {number} id - 交易记录ID
|
||||||
|
* @returns {Promise<{success: boolean, data: Object}>}
|
||||||
|
*/
|
||||||
|
export const getTransactionDetail = (id) => {
|
||||||
|
return request({
|
||||||
|
url: `/TransactionRecord/GetById/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建交易记录
|
||||||
|
* @param {Object} data - 交易记录数据
|
||||||
|
* @param {string} data.card - 卡号
|
||||||
|
* @param {string} data.occurredAt - 交易时间
|
||||||
|
* @param {string} data.reason - 交易摘要
|
||||||
|
* @param {number} data.amount - 交易金额
|
||||||
|
* @param {number} data.balance - 交易后余额
|
||||||
|
* @param {number} data.type - 交易类型 (0:支出, 1:收入, 2:不计入收支)
|
||||||
|
* @param {string} data.classify - 交易分类
|
||||||
|
* @param {string} data.subClassify - 交易子分类
|
||||||
|
* @returns {Promise<{success: boolean}>}
|
||||||
|
*/
|
||||||
|
export const createTransaction = (data) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionRecord/Create',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新交易记录
|
||||||
|
* @param {Object} data - 交易记录数据
|
||||||
|
* @param {number} data.id - 交易记录ID
|
||||||
|
* @param {number} data.amount - 交易金额
|
||||||
|
* @param {number} data.balance - 交易后余额
|
||||||
|
* @param {number} data.type - 交易类型 (0:支出, 1:收入, 2:不计入收支)
|
||||||
|
* @param {string} data.classify - 交易分类
|
||||||
|
* @param {string} data.subClassify - 交易子分类
|
||||||
|
* @returns {Promise<{success: boolean}>}
|
||||||
|
*/
|
||||||
|
export const updateTransaction = (data) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionRecord/Update',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除交易记录
|
||||||
|
* @param {number} id - 交易记录ID
|
||||||
|
* @returns {Promise<{success: boolean}>}
|
||||||
|
*/
|
||||||
|
export const deleteTransaction = (id) => {
|
||||||
|
return request({
|
||||||
|
url: `/TransactionRecord/DeleteById`,
|
||||||
|
method: 'post',
|
||||||
|
params: { id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定日期的交易记录
|
||||||
|
* @param {string} date - 日期字符串 (格式: yyyy-MM-dd)
|
||||||
|
* @returns {Promise<{success: boolean, data: Array}>}
|
||||||
|
*/
|
||||||
|
export const getTransactionsByDate = (date) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionRecord/GetByDate',
|
||||||
|
method: 'get',
|
||||||
|
params: { date }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 注意:分类相关的API已迁移到 transactionCategory.js
|
||||||
|
// 请使用 getCategoryTree 等新接口
|
||||||
86
Web/src/assets/base.css
Normal file
86
Web/src/assets/base.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/* color palette from <https://github.com/vuejs/theme> */
|
||||||
|
:root {
|
||||||
|
--vt-c-white: #ffffff;
|
||||||
|
--vt-c-white-soft: #f8f8f8;
|
||||||
|
--vt-c-white-mute: #f2f2f2;
|
||||||
|
|
||||||
|
--vt-c-black: #181818;
|
||||||
|
--vt-c-black-soft: #222222;
|
||||||
|
--vt-c-black-mute: #282828;
|
||||||
|
|
||||||
|
--vt-c-indigo: #2c3e50;
|
||||||
|
|
||||||
|
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||||
|
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||||
|
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||||
|
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||||
|
|
||||||
|
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||||
|
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||||
|
--vt-c-text-dark-1: var(--vt-c-white);
|
||||||
|
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* semantic color variables for this project */
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-white);
|
||||||
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-light-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-light-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
|
--color-text: var(--vt-c-text-light-1);
|
||||||
|
|
||||||
|
--section-gap: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-black);
|
||||||
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-dark-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
transition:
|
||||||
|
color 0.5s,
|
||||||
|
background-color 0.5s;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Oxygen,
|
||||||
|
Ubuntu,
|
||||||
|
Cantarell,
|
||||||
|
'Fira Sans',
|
||||||
|
'Droid Sans',
|
||||||
|
'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
1
Web/src/assets/logo.svg
Normal file
1
Web/src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||||
|
After Width: | Height: | Size: 276 B |
34
Web/src/assets/main.css
Normal file
34
Web/src/assets/main.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
@import './base.css';
|
||||||
|
|
||||||
|
#app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
.green {
|
||||||
|
text-decoration: none;
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
transition: 0.4s;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
a:hover {
|
||||||
|
background-color: hsla(160, 100%, 37%, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
521
Web/src/components/TransactionDetail.vue
Normal file
521
Web/src/components/TransactionDetail.vue
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
<template>
|
||||||
|
<van-popup
|
||||||
|
v-model:show="visible"
|
||||||
|
position="bottom"
|
||||||
|
:style="{ height: '85%' }"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
@update:show="handleVisibleChange"
|
||||||
|
>
|
||||||
|
<div class="transaction-detail" v-if="transaction">
|
||||||
|
<div class="detail-header" style="margin-top: 10px;margin-left: 10px;">
|
||||||
|
<h3>交易详情</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-form @submit="onSubmit">
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell title="卡号" :value="transaction.card" />
|
||||||
|
<van-cell title="交易时间" :value="formatDate(transaction.occurredAt)" />
|
||||||
|
<van-cell title="记录时间" :value="formatDate(transaction.createTime)" />
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<van-cell-group inset title="交易明细">
|
||||||
|
<van-field
|
||||||
|
v-model="editForm.reason"
|
||||||
|
name="reason"
|
||||||
|
label="交易摘要"
|
||||||
|
placeholder="请输入交易摘要"
|
||||||
|
type="textarea"
|
||||||
|
rows="2"
|
||||||
|
autosize
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="editForm.amount"
|
||||||
|
name="amount"
|
||||||
|
label="交易金额"
|
||||||
|
placeholder="请输入交易金额"
|
||||||
|
type="number"
|
||||||
|
:rules="[{ required: true, message: '请输入交易金额' }]"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="editForm.balance"
|
||||||
|
name="balance"
|
||||||
|
label="交易后余额"
|
||||||
|
placeholder="请输入交易后余额"
|
||||||
|
type="number"
|
||||||
|
:rules="[{ required: true, message: '请输入交易后余额' }]"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="editForm.typeText"
|
||||||
|
is-link
|
||||||
|
readonly
|
||||||
|
name="type"
|
||||||
|
label="交易类型"
|
||||||
|
placeholder="请选择交易类型"
|
||||||
|
@click="showTypePicker = true"
|
||||||
|
:rules="[{ required: true, message: '请选择交易类型' }]"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="editForm.classify"
|
||||||
|
is-link
|
||||||
|
readonly
|
||||||
|
name="classify"
|
||||||
|
label="交易分类"
|
||||||
|
placeholder="请选择或输入交易分类"
|
||||||
|
@click="openClassifyPicker"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="editForm.subClassify"
|
||||||
|
is-link
|
||||||
|
readonly
|
||||||
|
name="subClassify"
|
||||||
|
label="交易子分类"
|
||||||
|
placeholder="请选择或输入交易子分类"
|
||||||
|
@click="showSubClassifyPicker = true"
|
||||||
|
/>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<div style="margin: 16px;">
|
||||||
|
<van-button round block type="primary" native-type="submit" :loading="submitting">
|
||||||
|
保存修改
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</van-form>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 交易类型选择器 -->
|
||||||
|
<van-popup v-model:show="showTypePicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
show-toolbar
|
||||||
|
:columns="typeColumns"
|
||||||
|
@confirm="onTypeConfirm"
|
||||||
|
@cancel="showTypePicker = false"
|
||||||
|
/>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 交易分类选择器 -->
|
||||||
|
<van-popup v-model:show="showClassifyPicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
ref="classifyPickerRef"
|
||||||
|
:columns="classifyColumns"
|
||||||
|
@confirm="onClassifyConfirm"
|
||||||
|
@cancel="showClassifyPicker = false"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<div class="picker-toolbar">
|
||||||
|
<van-button class="toolbar-cancel" size="small" @click="clearClassify">清空</van-button>
|
||||||
|
<van-button class="toolbar-add" size="small" type="primary" @click="showAddClassify = true">新增</van-button>
|
||||||
|
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmClassify">确认</van-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-picker>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 交易子分类选择器 -->
|
||||||
|
<van-popup v-model:show="showSubClassifyPicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
ref="subClassifyPickerRef"
|
||||||
|
:columns="subClassifyColumns"
|
||||||
|
@confirm="onSubClassifyConfirm"
|
||||||
|
@cancel="showSubClassifyPicker = false"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<div class="picker-toolbar">
|
||||||
|
<van-button class="toolbar-cancel" size="small" @click="clearSubClassify">清空</van-button>
|
||||||
|
<van-button class="toolbar-add" size="small" type="primary" @click="showAddSubClassify = true">新增</van-button>
|
||||||
|
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmSubClassify">确认</van-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-picker>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 新增分类对话框 -->
|
||||||
|
<van-dialog
|
||||||
|
v-model:show="showAddClassify"
|
||||||
|
title="新增交易分类"
|
||||||
|
show-cancel-button
|
||||||
|
@confirm="addNewClassify"
|
||||||
|
>
|
||||||
|
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||||
|
</van-dialog>
|
||||||
|
|
||||||
|
<!-- 新增子分类对话框 -->
|
||||||
|
<van-dialog
|
||||||
|
v-model:show="showAddSubClassify"
|
||||||
|
title="新增交易子分类"
|
||||||
|
show-cancel-button
|
||||||
|
@confirm="addNewSubClassify"
|
||||||
|
>
|
||||||
|
<van-field v-model="newSubClassify" placeholder="请输入新的交易子分类" />
|
||||||
|
</van-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch, defineProps, defineEmits } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import { updateTransaction } from '@/api/transactionRecord'
|
||||||
|
import { getCategoryTree, createCategory } from '@/api/transactionCategory'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show', 'save'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
// 交易类型
|
||||||
|
const typeColumns = [
|
||||||
|
{ text: '支出', value: 0 },
|
||||||
|
{ text: '收入', value: 1 },
|
||||||
|
{ text: '不计入收支', value: 2 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 分类相关
|
||||||
|
const classifyColumns = ref([])
|
||||||
|
const subClassifyColumns = ref([])
|
||||||
|
const showTypePicker = ref(false)
|
||||||
|
const showClassifyPicker = ref(false)
|
||||||
|
const showSubClassifyPicker = ref(false)
|
||||||
|
const showAddClassify = ref(false)
|
||||||
|
const showAddSubClassify = ref(false)
|
||||||
|
const newClassify = ref('')
|
||||||
|
const newSubClassify = ref('')
|
||||||
|
const classifyPickerRef = ref(null)
|
||||||
|
const subClassifyPickerRef = ref(null)
|
||||||
|
|
||||||
|
// 编辑表单
|
||||||
|
const editForm = reactive({
|
||||||
|
id: 0,
|
||||||
|
reason: '',
|
||||||
|
amount: '',
|
||||||
|
balance: '',
|
||||||
|
type: 0,
|
||||||
|
typeText: '',
|
||||||
|
classify: '',
|
||||||
|
subClassify: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听props变化
|
||||||
|
watch(() => props.show, (newVal) => {
|
||||||
|
visible.value = newVal
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.transaction, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
// 填充编辑表单
|
||||||
|
editForm.id = newVal.id
|
||||||
|
editForm.reason = newVal.reason || ''
|
||||||
|
editForm.amount = String(newVal.amount)
|
||||||
|
editForm.balance = String(newVal.balance)
|
||||||
|
editForm.type = newVal.type
|
||||||
|
editForm.typeText = getTypeName(newVal.type)
|
||||||
|
editForm.classify = newVal.classify || ''
|
||||||
|
editForm.subClassify = newVal.subClassify || ''
|
||||||
|
|
||||||
|
// 根据交易类型加载分类
|
||||||
|
loadClassifyList(newVal.type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
emit('update:show', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听交易类型变化,重新加载分类
|
||||||
|
watch(() => editForm.type, (newVal) => {
|
||||||
|
// 清空已选的分类和子分类
|
||||||
|
editForm.classify = ''
|
||||||
|
editForm.subClassify = ''
|
||||||
|
// 重新加载对应类型的分类列表
|
||||||
|
loadClassifyList(newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleVisibleChange = (newVal) => {
|
||||||
|
emit('update:show', newVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载分类列表(从分类树中提取)
|
||||||
|
const loadClassifyList = async (type = null) => {
|
||||||
|
try {
|
||||||
|
const response = await getCategoryTree(type)
|
||||||
|
if (response.success) {
|
||||||
|
// 从树形结构中提取分类名称(Level 2)
|
||||||
|
classifyColumns.value = (response.data || []).map(item => ({
|
||||||
|
text: item.name,
|
||||||
|
value: item.name,
|
||||||
|
id: item.id,
|
||||||
|
children: item.children || []
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类列表出错:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载子分类列表(根据选中的分类)
|
||||||
|
const loadSubClassifyList = async (classifyName) => {
|
||||||
|
try {
|
||||||
|
// 从已加载的分类树中查找对应的子分类
|
||||||
|
const classifyItem = classifyColumns.value.find(item => item.value === classifyName)
|
||||||
|
if (classifyItem && classifyItem.children) {
|
||||||
|
subClassifyColumns.value = classifyItem.children.map(child => ({
|
||||||
|
text: child.name,
|
||||||
|
value: child.name,
|
||||||
|
id: child.id
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
subClassifyColumns.value = []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载子分类列表出错:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交编辑
|
||||||
|
const onSubmit = async () => {
|
||||||
|
try {
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
id: editForm.id,
|
||||||
|
reason: editForm.reason,
|
||||||
|
amount: parseFloat(editForm.amount),
|
||||||
|
balance: parseFloat(editForm.balance),
|
||||||
|
type: editForm.type,
|
||||||
|
classify: editForm.classify,
|
||||||
|
subClassify: editForm.subClassify
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await updateTransaction(data)
|
||||||
|
if (response.success) {
|
||||||
|
showToast('保存成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('save')
|
||||||
|
// 重新加载分类列表
|
||||||
|
await loadClassifyList(editForm.type)
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '保存失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存出错:', error)
|
||||||
|
showToast('保存失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开分类选择器
|
||||||
|
const openClassifyPicker = async () => {
|
||||||
|
// 先根据当前交易类型加载分类
|
||||||
|
await loadClassifyList(editForm.type)
|
||||||
|
showClassifyPicker.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交易类型选择确认
|
||||||
|
const onTypeConfirm = ({ selectedValues, selectedOptions }) => {
|
||||||
|
editForm.type = selectedValues[0]
|
||||||
|
editForm.typeText = selectedOptions[0].text
|
||||||
|
showTypePicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交易分类选择确认
|
||||||
|
const onClassifyConfirm = async ({ selectedOptions }) => {
|
||||||
|
if (selectedOptions && selectedOptions[0]) {
|
||||||
|
editForm.classify = selectedOptions[0].text
|
||||||
|
// 加载对应的子分类
|
||||||
|
await loadSubClassifyList(selectedOptions[0].value)
|
||||||
|
}
|
||||||
|
showClassifyPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交易子分类选择确认
|
||||||
|
const onSubClassifyConfirm = ({ selectedOptions }) => {
|
||||||
|
if (selectedOptions && selectedOptions[0]) {
|
||||||
|
editForm.subClassify = selectedOptions[0].text
|
||||||
|
}
|
||||||
|
showSubClassifyPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增分类
|
||||||
|
const addNewClassify = async () => {
|
||||||
|
if (!newClassify.value.trim()) {
|
||||||
|
showToast('请输入分类名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const categoryName = newClassify.value.trim()
|
||||||
|
|
||||||
|
// 调用API创建分类
|
||||||
|
const response = await createCategory({
|
||||||
|
name: categoryName,
|
||||||
|
parentId: 0,
|
||||||
|
type: editForm.type,
|
||||||
|
level: 2,
|
||||||
|
sortOrder: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showToast('分类创建成功')
|
||||||
|
// 重新加载分类列表
|
||||||
|
await loadClassifyList(editForm.type)
|
||||||
|
editForm.classify = categoryName
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '创建分类失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建分类出错:', error)
|
||||||
|
showToast('创建分类失败')
|
||||||
|
} finally {
|
||||||
|
newClassify.value = ''
|
||||||
|
showAddClassify.value = false
|
||||||
|
showClassifyPicker.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增子分类
|
||||||
|
const addNewSubClassify = async () => {
|
||||||
|
if (!newSubClassify.value.trim()) {
|
||||||
|
showToast('请输入子分类名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editForm.classify) {
|
||||||
|
showToast('请先选择分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subCategoryName = newSubClassify.value.trim()
|
||||||
|
|
||||||
|
// 找到父分类的ID
|
||||||
|
const parentCategory = classifyColumns.value.find(c => c.value === editForm.classify)
|
||||||
|
if (!parentCategory || !parentCategory.id) {
|
||||||
|
showToast('未找到父分类信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用API创建子分类
|
||||||
|
const response = await createCategory({
|
||||||
|
name: subCategoryName,
|
||||||
|
parentId: parentCategory.id,
|
||||||
|
type: editForm.type,
|
||||||
|
level: 3,
|
||||||
|
sortOrder: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showToast('子分类创建成功')
|
||||||
|
// 重新加载子分类列表
|
||||||
|
await loadSubClassifyList(editForm.classify)
|
||||||
|
editForm.subClassify = subCategoryName
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '创建子分类失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建子分类出错:', error)
|
||||||
|
showToast('创建子分类失败')
|
||||||
|
} finally {
|
||||||
|
newSubClassify.value = ''
|
||||||
|
showAddSubClassify.value = false
|
||||||
|
showSubClassifyPicker.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空分类
|
||||||
|
const clearClassify = () => {
|
||||||
|
editForm.classify = ''
|
||||||
|
showClassifyPicker.value = false
|
||||||
|
showToast('已清空分类')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空子分类
|
||||||
|
const clearSubClassify = () => {
|
||||||
|
editForm.subClassify = ''
|
||||||
|
showSubClassifyPicker.value = false
|
||||||
|
showToast('已清空子分类')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认分类(从 picker 中获取选中值)
|
||||||
|
const confirmClassify = () => {
|
||||||
|
if (classifyPickerRef.value) {
|
||||||
|
const selectedValues = classifyPickerRef.value.getSelectedOptions()
|
||||||
|
if (selectedValues && selectedValues[0]) {
|
||||||
|
editForm.classify = selectedValues[0].text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showClassifyPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认子分类(从 picker 中获取选中值)
|
||||||
|
const confirmSubClassify = () => {
|
||||||
|
if (subClassifyPickerRef.value) {
|
||||||
|
const selectedValues = subClassifyPickerRef.value.getSelectedOptions()
|
||||||
|
if (selectedValues && selectedValues[0]) {
|
||||||
|
editForm.subClassify = selectedValues[0].text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showSubClassifyPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取交易类型名称
|
||||||
|
const getTypeName = (type) => {
|
||||||
|
const typeMap = {
|
||||||
|
0: '支出',
|
||||||
|
1: '收入',
|
||||||
|
2: '不计入收支'
|
||||||
|
}
|
||||||
|
return typeMap[type] || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return ''
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transaction-detail {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header h3 {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-toolbar {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #ebedf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-cancel {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-confirm {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
251
Web/src/components/TransactionList.vue
Normal file
251
Web/src/components/TransactionList.vue
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<template>
|
||||||
|
<div class="transaction-list-container">
|
||||||
|
<van-list
|
||||||
|
:loading="loading"
|
||||||
|
:finished="finished"
|
||||||
|
finished-text="没有更多了"
|
||||||
|
@load="onLoad"
|
||||||
|
>
|
||||||
|
<van-cell-group v-if="transactions && transactions.length" inset style="margin-top: 10px">
|
||||||
|
<van-swipe-cell
|
||||||
|
v-for="transaction in transactions"
|
||||||
|
:key="transaction.id"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="transaction-card"
|
||||||
|
@click="handleClick(transaction)"
|
||||||
|
>
|
||||||
|
<div class="card-left">
|
||||||
|
<div class="transaction-title">
|
||||||
|
<span class="reason">{{ transaction.reason || '(无摘要)' }}</span>
|
||||||
|
<van-tag
|
||||||
|
:type="getTypeTagType(transaction.type)"
|
||||||
|
size="medium"
|
||||||
|
>
|
||||||
|
{{ getTypeName(transaction.type) }}
|
||||||
|
</van-tag>
|
||||||
|
</div>
|
||||||
|
<div class="transaction-info">
|
||||||
|
<div>交易时间: {{ formatDate(transaction.occurredAt) }}</div>
|
||||||
|
<div v-if="transaction.classify">分类: {{ transaction.classify }}
|
||||||
|
<span v-if="transaction.subClassify">/ {{ transaction.subClassify }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="transaction.card">
|
||||||
|
卡号: {{ transaction.card }}
|
||||||
|
</div>
|
||||||
|
<div v-if="transaction.importFrom">
|
||||||
|
来源: {{ transaction.importFrom }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-right">
|
||||||
|
<div class="transaction-amount">
|
||||||
|
<div :class="['amount', getAmountClass(transaction.type)]">
|
||||||
|
{{ formatAmount(transaction.amount, transaction.type) }}
|
||||||
|
</div>
|
||||||
|
<div class="balance" v-if="transaction.balance && transaction.balance > 0">
|
||||||
|
余额: {{ formatMoney(transaction.balance) }}
|
||||||
|
</div>
|
||||||
|
<div class="balance" v-if="transaction.refundAmount && transaction.refundAmount > 0">
|
||||||
|
退款: {{ formatMoney(transaction.refundAmount) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<van-icon name="arrow" size="16" color="#c8c9cc" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #right v-if="showDelete">
|
||||||
|
<van-button
|
||||||
|
square
|
||||||
|
type="danger"
|
||||||
|
text="删除"
|
||||||
|
class="delete-button"
|
||||||
|
@click="handleDeleteClick(transaction)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</van-swipe-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<van-empty
|
||||||
|
v-if="!loading && !(transactions && transactions.length)"
|
||||||
|
description="暂无交易记录"
|
||||||
|
/>
|
||||||
|
</van-list>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineEmits } from 'vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
transactions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
finished: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showDelete: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['load', 'click', 'delete'])
|
||||||
|
|
||||||
|
const onLoad = () => {
|
||||||
|
emit('load')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (transaction) => {
|
||||||
|
emit('click', transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClick = (transaction) => {
|
||||||
|
emit('delete', transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取交易类型名称
|
||||||
|
const getTypeName = (type) => {
|
||||||
|
const typeMap = {
|
||||||
|
0: '支出',
|
||||||
|
1: '收入',
|
||||||
|
2: '不计入收支'
|
||||||
|
}
|
||||||
|
return typeMap[type] || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取交易类型标签类型
|
||||||
|
const getTypeTagType = (type) => {
|
||||||
|
const typeMap = {
|
||||||
|
0: 'danger',
|
||||||
|
1: 'success',
|
||||||
|
2: 'default'
|
||||||
|
}
|
||||||
|
return typeMap[type] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取金额样式类
|
||||||
|
const getAmountClass = (type) => {
|
||||||
|
if (type === 0) return 'expense'
|
||||||
|
if (type === 1) return 'income'
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化金额(带符号)
|
||||||
|
const formatAmount = (amount, type) => {
|
||||||
|
const formatted = formatMoney(amount)
|
||||||
|
if (type === 0) return `- ${formatted}`
|
||||||
|
if (type === 1) return `+ ${formatted}`
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化金额
|
||||||
|
const formatMoney = (amount) => {
|
||||||
|
return `¥${Number(amount).toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return ''
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transaction-list-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-left {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #969799;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-amount {
|
||||||
|
text-align: right;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount.expense {
|
||||||
|
color: #ee0a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount.income {
|
||||||
|
color: #07c160;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount.neutral {
|
||||||
|
color: #646566;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #969799;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
Web/src/main.js
Normal file
19
Web/src/main.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import vant from 'vant'
|
||||||
|
import { ConfigProvider } from 'vant';
|
||||||
|
import 'vant/lib/index.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(vant)
|
||||||
|
app.use(ConfigProvider);
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
29
Web/src/router/index.js
Normal file
29
Web/src/router/index.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'transactions',
|
||||||
|
component: () => import('../views/TransactionsRecord.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/email',
|
||||||
|
name: 'email',
|
||||||
|
component: () => import('../views/EmailRecord.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/setting',
|
||||||
|
name: 'setting',
|
||||||
|
component: () => import('../views/SettingView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/calendar',
|
||||||
|
name: 'calendar',
|
||||||
|
component: () => import('../views/CalendarView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
12
Web/src/stores/counter.js
Normal file
12
Web/src/stores/counter.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useCounterStore = defineStore('counter', () => {
|
||||||
|
const count = ref(0)
|
||||||
|
const doubleCount = computed(() => count.value * 2)
|
||||||
|
function increment() {
|
||||||
|
count.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count, doubleCount, increment }
|
||||||
|
})
|
||||||
109
Web/src/styles/common.css
Normal file
109
Web/src/styles/common.css
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/* 通用页面容器样式 */
|
||||||
|
.page-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.page-container {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下拉刷新包装器 */
|
||||||
|
.refresh-wrapper {
|
||||||
|
min-height: calc(100vh - 46px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 增加卡片组的对比度 */
|
||||||
|
:deep(.van-cell-group--inset) {
|
||||||
|
margin: 10px 16px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:deep(.van-cell-group--inset) {
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单元格样式 */
|
||||||
|
:deep(.van-cell) {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:deep(.van-cell) {
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.van-cell:last-child) {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情弹出层样式 */
|
||||||
|
.detail-popup {
|
||||||
|
padding: 16px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.detail-popup {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹出层内的卡片组样式 */
|
||||||
|
.detail-popup :deep(.van-cell-group--inset) {
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.detail-popup :deep(.van-cell-group--inset) {
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情头部样式 */
|
||||||
|
.detail-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #969799;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导航栏透明背景 */
|
||||||
|
:deep(.van-nav-bar) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 修复表单字段过长时的换行显示 */
|
||||||
|
:deep(.van-field__control) {
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.van-field__value) {
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
226
Web/src/views/CalendarView.vue
Normal file
226
Web/src/views/CalendarView.vue
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<div class="calendar-container">
|
||||||
|
<van-calendar
|
||||||
|
title="日历"
|
||||||
|
:poppable="false"
|
||||||
|
:show-confirm="false"
|
||||||
|
:formatter="formatterCalendar"
|
||||||
|
:min-date="minDate"
|
||||||
|
:max-date="maxDate"
|
||||||
|
@month-show="onMonthShow"
|
||||||
|
@select="onDateSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 日期交易列表弹出层 -->
|
||||||
|
<van-popup
|
||||||
|
v-model:show="listVisible"
|
||||||
|
position="bottom"
|
||||||
|
:style="{ height: '85%' }"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
>
|
||||||
|
<div class="date-transactions">
|
||||||
|
<div class="popup-header">
|
||||||
|
<h3>{{ selectedDateText }}</h3>
|
||||||
|
<p v-if="dateTransactions.length">共 {{ dateTransactions.length }} 笔交易</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TransactionList
|
||||||
|
:transactions="dateTransactions"
|
||||||
|
:loading="listLoading"
|
||||||
|
:finished="true"
|
||||||
|
:show-delete="false"
|
||||||
|
@click="viewDetail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 交易详情组件 -->
|
||||||
|
<TransactionDetail
|
||||||
|
v-model:show="detailVisible"
|
||||||
|
:transaction="currentTransaction"
|
||||||
|
@save="onDetailSave"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import request from '@/api/request'
|
||||||
|
import { getTransactionDetail, getTransactionsByDate } from '@/api/transactionRecord'
|
||||||
|
import TransactionList from '@/components/TransactionList.vue'
|
||||||
|
import TransactionDetail from '@/components/TransactionDetail.vue'
|
||||||
|
|
||||||
|
const dailyStatistics = ref({})
|
||||||
|
const listVisible = ref(false)
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const dateTransactions = ref([])
|
||||||
|
const currentTransaction = ref(null)
|
||||||
|
const listLoading = ref(false)
|
||||||
|
const selectedDate = ref(null)
|
||||||
|
const selectedDateText = ref('')
|
||||||
|
|
||||||
|
// 设置日历可选范围(例如:过去2年到未来1年)
|
||||||
|
const minDate = new Date(new Date().getFullYear() - 2, 0, 1) // 2年前的1月1日
|
||||||
|
const maxDate = new Date(new Date().getFullYear() + 1, 11, 31) // 明年12月31日
|
||||||
|
|
||||||
|
// 获取日历统计数据
|
||||||
|
const fetchDailyStatistics = async (year, month) => {
|
||||||
|
try {
|
||||||
|
const response = await request.get('/TransactionRecord/GetDailyStatistics', {
|
||||||
|
params: { year, month }
|
||||||
|
})
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 将数组转换为对象,key为日期
|
||||||
|
const statsMap = {}
|
||||||
|
response.data.forEach(item => {
|
||||||
|
statsMap[item.date] = {
|
||||||
|
count: item.count,
|
||||||
|
amount: item.amount
|
||||||
|
}
|
||||||
|
})
|
||||||
|
dailyStatistics.value = {
|
||||||
|
...dailyStatistics.value,
|
||||||
|
...statsMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取日历统计数据失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定日期的交易列表
|
||||||
|
const fetchDateTransactions = async (date) => {
|
||||||
|
try {
|
||||||
|
listLoading.value = true
|
||||||
|
const dateStr = date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
|
||||||
|
|
||||||
|
const response = await getTransactionsByDate(dateStr)
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
dateTransactions.value = response.data
|
||||||
|
} else {
|
||||||
|
dateTransactions.value = []
|
||||||
|
showToast(response.message || '获取交易列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取日期交易列表失败:', error)
|
||||||
|
dateTransactions.value = []
|
||||||
|
showToast('获取交易列表失败')
|
||||||
|
} finally {
|
||||||
|
listLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当月份显示时触发
|
||||||
|
const onMonthShow = ({ date }) => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth() + 1
|
||||||
|
fetchDailyStatistics(year, month)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期选择事件
|
||||||
|
const onDateSelect = (date) => {
|
||||||
|
selectedDate.value = date
|
||||||
|
selectedDateText.value = formatSelectedDate(date)
|
||||||
|
fetchDateTransactions(date)
|
||||||
|
listVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化选中的日期
|
||||||
|
const formatSelectedDate = (date) => {
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'long'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const viewDetail = async (transaction) => {
|
||||||
|
try {
|
||||||
|
const response = await getTransactionDetail(transaction.id)
|
||||||
|
if (response.success) {
|
||||||
|
currentTransaction.value = response.data
|
||||||
|
detailVisible.value = true
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '获取详情失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取详情出错:', error)
|
||||||
|
showToast('获取详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详情保存后的回调
|
||||||
|
const onDetailSave = () => {
|
||||||
|
// 重新加载当前日期的交易列表
|
||||||
|
if (selectedDate.value) {
|
||||||
|
fetchDateTransactions(selectedDate.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新加载当前月份的统计数据
|
||||||
|
const now = selectedDate.value || new Date()
|
||||||
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatterCalendar = (day) => {
|
||||||
|
const dayCopy = { ...day };
|
||||||
|
if (dayCopy.date.toDateString() === new Date().toDateString()) {
|
||||||
|
dayCopy.text = '今天';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期为 yyyy-MM-dd
|
||||||
|
const dateKey = dayCopy.date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-');
|
||||||
|
const stats = dailyStatistics.value[dateKey]
|
||||||
|
|
||||||
|
if (stats) {
|
||||||
|
dayCopy.topInfo = `${stats.count}笔` // 展示消费笔数
|
||||||
|
dayCopy.bottomInfo = `${stats.amount.toFixed(1)}元` // 展示消费金额
|
||||||
|
}
|
||||||
|
|
||||||
|
return dayCopy;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始加载当前月份数据
|
||||||
|
const now = new Date()
|
||||||
|
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.calendar-container {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-container :deep(.van-calendar) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-transactions {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #ebedf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
474
Web/src/views/EmailRecord.vue
Normal file
474
Web/src/views/EmailRecord.vue
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
<template>
|
||||||
|
<div class="email-record-container">
|
||||||
|
<!-- 顶部导航栏 -->
|
||||||
|
<van-nav-bar title="邮件记录" fixed placeholder>
|
||||||
|
<template #right>
|
||||||
|
<van-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="syncing"
|
||||||
|
@click="handleSync"
|
||||||
|
>
|
||||||
|
立即同步
|
||||||
|
</van-button>
|
||||||
|
</template>
|
||||||
|
</van-nav-bar>
|
||||||
|
|
||||||
|
<!-- 下拉刷新区域 -->
|
||||||
|
<van-pull-refresh v-model="refreshing" @refresh="onRefresh" class="refresh-wrapper">
|
||||||
|
<!-- 加载提示 -->
|
||||||
|
<van-loading v-if="loading && !(emailList && emailList.length)" vertical style="padding: 50px 0">
|
||||||
|
加载中...
|
||||||
|
</van-loading>
|
||||||
|
|
||||||
|
<!-- 邮件列表 -->
|
||||||
|
<van-list
|
||||||
|
v-model:loading="loading"
|
||||||
|
:finished="finished"
|
||||||
|
finished-text="没有更多了"
|
||||||
|
@load="onLoad"
|
||||||
|
>
|
||||||
|
<van-cell-group v-if="emailList && emailList.length" inset style="margin-top: 10px">
|
||||||
|
<van-swipe-cell
|
||||||
|
v-for="email in emailList"
|
||||||
|
:key="email.id"
|
||||||
|
>
|
||||||
|
<van-cell
|
||||||
|
:title="email.subject"
|
||||||
|
:label="`来自: ${email.from}`"
|
||||||
|
is-link
|
||||||
|
@click="viewDetail(email)"
|
||||||
|
>
|
||||||
|
<template #value>
|
||||||
|
<div class="email-info">
|
||||||
|
<div class="email-date">{{ formatDate(email.receivedDate) }}</div>
|
||||||
|
<div class="bill-count" v-if="email.transactionCount > 0">
|
||||||
|
<span style="font-size: 12px;">已解析{{ email.transactionCount }}条账单</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<template #right>
|
||||||
|
<van-button
|
||||||
|
square
|
||||||
|
type="danger"
|
||||||
|
text="删除"
|
||||||
|
class="delete-button"
|
||||||
|
@click="handleDelete(email)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</van-swipe-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<van-empty
|
||||||
|
v-if="!loading && !(emailList && emailList.length)"
|
||||||
|
description="暂无邮件记录"
|
||||||
|
/>
|
||||||
|
</van-list>
|
||||||
|
</van-pull-refresh>
|
||||||
|
|
||||||
|
<!-- 详情弹出层 -->
|
||||||
|
<van-popup
|
||||||
|
v-model:show="detailVisible"
|
||||||
|
position="bottom"
|
||||||
|
:style="{ height: '80%' }"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
>
|
||||||
|
<div class="email-detail" v-if="currentEmail">
|
||||||
|
<div class="detail-header" style="margin-top: 10px; margin-left: 10px;">
|
||||||
|
<h3>{{ currentEmail.Subject || currentEmail.subject || '(无主题)' }}</h3>
|
||||||
|
</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell title="发件人" :value="currentEmail.From || currentEmail.from || '未知'" />
|
||||||
|
<van-cell title="接收时间" :value="formatDate(currentEmail.ReceivedDate || currentEmail.receivedDate)" />
|
||||||
|
<van-cell title="记录时间" :value="formatDate(currentEmail.CreateTime || currentEmail.createTime)" />
|
||||||
|
<van-cell
|
||||||
|
title="已解析账单数"
|
||||||
|
:value="`${currentEmail.TransactionCount || currentEmail.transactionCount || 0}条`"
|
||||||
|
is-link
|
||||||
|
@click="viewTransactions"
|
||||||
|
v-if="(currentEmail.TransactionCount || currentEmail.transactionCount || 0) > 0"
|
||||||
|
/>
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="email-content">
|
||||||
|
<h4 style="margin-left: 10px;">邮件内容</h4>
|
||||||
|
<div
|
||||||
|
v-if="currentEmail.htmlBody"
|
||||||
|
v-html="currentEmail.htmlBody"
|
||||||
|
class="content-body html-content"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
v-else-if="currentEmail.body"
|
||||||
|
class="content-body"
|
||||||
|
>
|
||||||
|
{{ currentEmail.body }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="content-body empty-content">
|
||||||
|
暂无邮件内容
|
||||||
|
<div style="font-size: 12px; margin-top: 8px; color: #999;">
|
||||||
|
Debug: {{ Object.keys(currentEmail).join(', ') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 16px;">
|
||||||
|
<van-button
|
||||||
|
round
|
||||||
|
block
|
||||||
|
type="primary"
|
||||||
|
:loading="refreshingAnalysis"
|
||||||
|
@click="handleRefreshAnalysis"
|
||||||
|
>
|
||||||
|
重新分析
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 账单列表弹出层 -->
|
||||||
|
<van-popup
|
||||||
|
v-model:show="transactionListVisible"
|
||||||
|
position="bottom"
|
||||||
|
:style="{ height: '70%' }"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
>
|
||||||
|
<div class="transaction-list-popup">
|
||||||
|
<div class="list-header">
|
||||||
|
<h3 style="margin: 16px;">关联账单列表</h3>
|
||||||
|
</div>
|
||||||
|
<TransactionList
|
||||||
|
:transactions="transactionList"
|
||||||
|
:loading="false"
|
||||||
|
:finished="true"
|
||||||
|
:show-delete="false"
|
||||||
|
@click="handleTransactionClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 账单详情编辑弹出层 -->
|
||||||
|
<TransactionDetail
|
||||||
|
:show="transactionDetailVisible"
|
||||||
|
:transaction="currentTransaction"
|
||||||
|
@update:show="transactionDetailVisible = $event"
|
||||||
|
@save="handleTransactionSave"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { showToast, showConfirmDialog } from 'vant'
|
||||||
|
import { getEmailList, getEmailDetail, deleteEmail, refreshTransactionRecords, syncEmails, getEmailTransactions } from '@/api/emailRecord'
|
||||||
|
import { getTransactionDetail } from '@/api/transactionRecord'
|
||||||
|
import TransactionList from '@/components/TransactionList.vue'
|
||||||
|
import TransactionDetail from '@/components/TransactionDetail.vue'
|
||||||
|
|
||||||
|
const emailList = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const finished = ref(false)
|
||||||
|
const lastId = ref(null) // 游标分页:记录最后一条记录的ID
|
||||||
|
const lastTime = ref(null) // 游标分页:记录最后一条记录的时间
|
||||||
|
const total = ref(0)
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const currentEmail = ref(null)
|
||||||
|
const refreshingAnalysis = ref(false)
|
||||||
|
const syncing = ref(false)
|
||||||
|
const transactionListVisible = ref(false)
|
||||||
|
const transactionList = ref([])
|
||||||
|
const transactionDetailVisible = ref(false)
|
||||||
|
const currentTransaction = ref(null)
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async (isRefresh = false) => {
|
||||||
|
if (loading.value) return // 防止重复加载
|
||||||
|
|
||||||
|
if (isRefresh) {
|
||||||
|
lastId.value = null
|
||||||
|
lastTime.value = null
|
||||||
|
emailList.value = []
|
||||||
|
finished.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {}
|
||||||
|
if (lastTime.value && lastId.value) {
|
||||||
|
params.lastReceivedDate = lastTime.value
|
||||||
|
params.lastId = lastId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getEmailList(params)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
const newList = response.data || []
|
||||||
|
total.value = response.total || 0
|
||||||
|
const newLastId = response.lastId || 0
|
||||||
|
const newLastTime = response.lastTime
|
||||||
|
|
||||||
|
if (isRefresh) {
|
||||||
|
emailList.value = newList
|
||||||
|
} else {
|
||||||
|
emailList.value = [...(emailList.value || []), ...newList]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新游标
|
||||||
|
if (newLastId > 0 && newLastTime) {
|
||||||
|
lastId.value = newLastId
|
||||||
|
lastTime.value = newLastTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否还有更多数据(返回数据少于20条或为空,说明没有更多了)
|
||||||
|
if (newList.length === 0 || newList.length < 20) {
|
||||||
|
finished.value = true
|
||||||
|
} else {
|
||||||
|
finished.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '加载数据失败')
|
||||||
|
finished.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载数据出错:', error)
|
||||||
|
showToast('加载数据出错: ' + (error.message || '未知错误'))
|
||||||
|
finished.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
const onRefresh = () => {
|
||||||
|
loadData(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
const onLoad = () => {
|
||||||
|
if (!finished.value && !loading.value) {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const viewDetail = async (email) => {
|
||||||
|
try {
|
||||||
|
const response = await getEmailDetail(email.id)
|
||||||
|
console.log('详情 API 返回:', response)
|
||||||
|
if (response.success) {
|
||||||
|
currentEmail.value = response.data
|
||||||
|
console.log('currentEmail:', currentEmail.value)
|
||||||
|
detailVisible.value = true
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '获取详情失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取详情出错:', error)
|
||||||
|
showToast('获取详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = async (email) => {
|
||||||
|
try {
|
||||||
|
await showConfirmDialog({
|
||||||
|
title: '提示',
|
||||||
|
message: '确定要删除这封邮件吗?',
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await deleteEmail(email.id)
|
||||||
|
if (response.success) {
|
||||||
|
showToast('删除成功')
|
||||||
|
loadData(true)
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('删除出错:', error)
|
||||||
|
showToast('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新分析
|
||||||
|
const handleRefreshAnalysis = async () => {
|
||||||
|
if (!currentEmail.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await showConfirmDialog({
|
||||||
|
title: '提示',
|
||||||
|
message: '确定要重新分析该邮件并刷新交易记录吗?',
|
||||||
|
})
|
||||||
|
|
||||||
|
refreshingAnalysis.value = true
|
||||||
|
const response = await refreshTransactionRecords(currentEmail.value.id || currentEmail.value.Id)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showToast('重新分析成功')
|
||||||
|
detailVisible.value = false
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '重新分析失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('重新分析出错:', error)
|
||||||
|
showToast('重新分析失败: ' + (error.message || '未知错误'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refreshingAnalysis.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即同步
|
||||||
|
const handleSync = async () => {
|
||||||
|
try {
|
||||||
|
syncing.value = true
|
||||||
|
const response = await syncEmails()
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showToast(response.message || '同步成功')
|
||||||
|
// 同步成功后刷新列表
|
||||||
|
loadData(true)
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '同步失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('同步出错:', error)
|
||||||
|
showToast('同步失败: ' + (error.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
syncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看关联的账单列表
|
||||||
|
const viewTransactions = async () => {
|
||||||
|
if (!currentEmail.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emailId = currentEmail.value.id || currentEmail.value.Id
|
||||||
|
const response = await getEmailTransactions(emailId)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
transactionList.value = response.data || []
|
||||||
|
transactionListVisible.value = true
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '获取账单列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取账单列表出错:', error)
|
||||||
|
showToast('获取账单列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理点击账单
|
||||||
|
const handleTransactionClick = async (transaction) => {
|
||||||
|
try {
|
||||||
|
const response = await getTransactionDetail(transaction.id)
|
||||||
|
if (response.success) {
|
||||||
|
currentTransaction.value = response.data
|
||||||
|
transactionDetailVisible.value = true
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '获取账单详情失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取账单详情出错:', error)
|
||||||
|
showToast('获取账单详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账单保存后刷新列表
|
||||||
|
const handleTransactionSave = async () => {
|
||||||
|
// 刷新账单列表
|
||||||
|
if (currentEmail.value) {
|
||||||
|
const emailId = currentEmail.value.id || currentEmail.value.Id
|
||||||
|
const response = await getEmailTransactions(emailId)
|
||||||
|
if (response.success) {
|
||||||
|
transactionList.value = response.data || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return ''
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData(true)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import '@/styles/common.css';
|
||||||
|
|
||||||
|
.email-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bill-count {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #969799;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-list-popup {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-content {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-content h4 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.content-body {
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
134
Web/src/views/SettingView.vue
Normal file
134
Web/src/views/SettingView.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<van-nav-bar title="设置" />
|
||||||
|
<div class="detail-header">
|
||||||
|
<p>账单导入</p>
|
||||||
|
</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell title="从支付宝导入" is-link @click="handleImportClick('Alipay')" />
|
||||||
|
<van-cell title="从微信导入" is-link @click="handleImportClick('WeChat')" />
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<!-- 隐藏的文件选择器 -->
|
||||||
|
<input ref="fileInputRef" type="file" accept=".csv,.xlsx,.xls" style="display: none" @change="handleFileChange" />
|
||||||
|
|
||||||
|
<div class="detail-header">
|
||||||
|
<p>账单处理</p>
|
||||||
|
</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell title="智能分类" is-link />
|
||||||
|
</van-cell-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { showLoadingToast, showSuccessToast, showToast, closeToast } from 'vant'
|
||||||
|
import { uploadBillFile } from '@/api/billImport'
|
||||||
|
|
||||||
|
const fileInputRef = ref(null)
|
||||||
|
const currentType = ref('')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理导入按钮点击
|
||||||
|
*/
|
||||||
|
const handleImportClick = (type) => {
|
||||||
|
currentType.value = type
|
||||||
|
// 触发文件选择
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文件选择
|
||||||
|
*/
|
||||||
|
const handleFileChange = async (event) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
const validTypes = ['text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
showToast('请选择 CSV 或 Excel 文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小(限制为 10MB)
|
||||||
|
const maxSize = 10 * 1024 * 1024
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
showToast('文件大小不能超过 10MB')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 显示加载提示
|
||||||
|
showLoadingToast({
|
||||||
|
message: '上传中...',
|
||||||
|
forbidClick: true,
|
||||||
|
duration: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 上传文件
|
||||||
|
const typeName = currentType.value === 'Alipay' ? '支付宝' : '微信'
|
||||||
|
const { success, message } = await uploadBillFile(file, currentType.value)
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
showToast(message || `${typeName}账单导入失败`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccessToast(message || `${typeName}账单导入成功`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传失败:', error)
|
||||||
|
showToast('上传失败: ' + (error.message || '未知错误'))
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
closeToast()
|
||||||
|
// 清空文件输入,允许重复选择同一文件
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 设置页面容器背景色 */
|
||||||
|
:deep(.van-nav-bar) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面背景色 */
|
||||||
|
:deep(body) {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:deep(body) {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 增加卡片对比度 */
|
||||||
|
:deep(.van-cell-group--inset) {
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:deep(.van-cell-group--inset) {
|
||||||
|
background-color: #2c2c2c;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
padding: 16px 16px 5px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #969799;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
607
Web/src/views/TransactionsRecord.vue
Normal file
607
Web/src/views/TransactionsRecord.vue
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
<template>
|
||||||
|
<div class="transaction-record-container">
|
||||||
|
<!-- 顶部导航栏 -->
|
||||||
|
<van-nav-bar title="交易记录" fixed placeholder style="z-index: 9999;">
|
||||||
|
<template #right>
|
||||||
|
<van-button type="primary" size="small" @click="openAddDialog">
|
||||||
|
手动录账
|
||||||
|
</van-button>
|
||||||
|
</template>
|
||||||
|
</van-nav-bar>
|
||||||
|
|
||||||
|
<!-- 下拉刷新区域 -->
|
||||||
|
<van-pull-refresh v-model="refreshing" @refresh="onRefresh" class="refresh-wrapper">
|
||||||
|
<!-- 加载提示 -->
|
||||||
|
<van-loading v-if="loading && !(transactionList && transactionList.length)" vertical style="padding: 50px 0">
|
||||||
|
加载中...
|
||||||
|
</van-loading>
|
||||||
|
|
||||||
|
<!-- 交易记录列表 -->
|
||||||
|
<TransactionList
|
||||||
|
:transactions="transactionList"
|
||||||
|
:loading="loading"
|
||||||
|
:finished="finished"
|
||||||
|
@load="onLoad"
|
||||||
|
@click="viewDetail"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
</van-pull-refresh>
|
||||||
|
|
||||||
|
<!-- 详情/编辑弹出层 -->
|
||||||
|
<TransactionDetail
|
||||||
|
v-model:show="detailVisible"
|
||||||
|
:transaction="currentTransaction"
|
||||||
|
@save="onDetailSave"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 新增交易记录弹出层 -->
|
||||||
|
<van-popup
|
||||||
|
v-model:show="addDialogVisible"
|
||||||
|
position="bottom"
|
||||||
|
:style="{ height: '85%' }"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
>
|
||||||
|
<div class="transaction-detail">
|
||||||
|
<div class="detail-header" style="padding-top: 10px; padding-left: 10px;">
|
||||||
|
<h3>手动录账单</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-form @submit="onAddSubmit">
|
||||||
|
<van-cell-group inset title="基本信息">
|
||||||
|
<van-field
|
||||||
|
v-model="addForm.occurredAt"
|
||||||
|
is-link
|
||||||
|
readonly
|
||||||
|
name="occurredAt"
|
||||||
|
label="交易时间"
|
||||||
|
placeholder="请选择交易时间"
|
||||||
|
@click="showDateTimePicker = true"
|
||||||
|
:rules="[{ required: true, message: '请选择交易时间' }]"
|
||||||
|
/>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<van-cell-group inset title="交易明细">
|
||||||
|
<van-field
|
||||||
|
v-model="addForm.reason"
|
||||||
|
name="reason"
|
||||||
|
label="交易摘要"
|
||||||
|
placeholder="请输入交易摘要"
|
||||||
|
type="textarea"
|
||||||
|
rows="2"
|
||||||
|
autosize
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="addForm.amount"
|
||||||
|
name="amount"
|
||||||
|
label="交易金额"
|
||||||
|
placeholder="请输入交易金额"
|
||||||
|
type="number"
|
||||||
|
:rules="[{ required: true, message: '请输入交易金额' }]"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="addForm.typeText"
|
||||||
|
is-link
|
||||||
|
readonly
|
||||||
|
name="type"
|
||||||
|
label="交易类型"
|
||||||
|
placeholder="请选择交易类型"
|
||||||
|
@click="showAddTypePicker = true"
|
||||||
|
:rules="[{ required: true, message: '请选择交易类型' }]"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="addForm.classify"
|
||||||
|
is-link
|
||||||
|
readonly
|
||||||
|
name="classify"
|
||||||
|
label="交易分类"
|
||||||
|
placeholder="请选择或输入交易分类"
|
||||||
|
@click="showAddClassifyPicker = true"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="addForm.subClassify"
|
||||||
|
is-link
|
||||||
|
readonly
|
||||||
|
name="subClassify"
|
||||||
|
label="交易子分类"
|
||||||
|
placeholder="请选择或输入交易子分类"
|
||||||
|
@click="showAddSubClassifyPicker = true"
|
||||||
|
/>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<div style="margin: 16px;">
|
||||||
|
<van-button round block type="primary" native-type="submit" :loading="addSubmitting">
|
||||||
|
确认添加
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</van-form>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 新增交易 - 日期时间选择器 -->
|
||||||
|
<van-popup v-model:show="showDateTimePicker" position="bottom" round>
|
||||||
|
<van-date-picker
|
||||||
|
v-model="dateTimeValue"
|
||||||
|
title="选择日期时间"
|
||||||
|
:min-date="new Date(2020, 0, 1)"
|
||||||
|
:max-date="new Date()"
|
||||||
|
@confirm="onDateTimeConfirm"
|
||||||
|
@cancel="showDateTimePicker = false"
|
||||||
|
/>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 新增交易 - 交易类型选择器 -->
|
||||||
|
<van-popup v-model:show="showAddTypePicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
show-toolbar
|
||||||
|
:columns="typeColumns"
|
||||||
|
@confirm="onAddTypeConfirm"
|
||||||
|
@cancel="showAddTypePicker = false"
|
||||||
|
/>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 新增交易 - 交易分类选择器 -->
|
||||||
|
<van-popup v-model:show="showAddClassifyPicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
ref="addClassifyPickerRef"
|
||||||
|
:columns="classifyColumns"
|
||||||
|
@confirm="onAddClassifyConfirm"
|
||||||
|
@cancel="showAddClassifyPicker = false"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<div class="picker-toolbar">
|
||||||
|
<van-button class="toolbar-cancel" size="small" @click="clearAddClassify">清空</van-button>
|
||||||
|
<van-button class="toolbar-add" size="small" type="primary" @click="showAddClassify = true">新增</van-button>
|
||||||
|
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmAddClassify">确认</van-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-picker>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 新增交易 - 交易子分类选择器 -->
|
||||||
|
<van-popup v-model:show="showAddSubClassifyPicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
ref="addSubClassifyPickerRef"
|
||||||
|
:columns="subClassifyColumns"
|
||||||
|
@confirm="onAddSubClassifyConfirm"
|
||||||
|
@cancel="showAddSubClassifyPicker = false"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<div class="picker-toolbar">
|
||||||
|
<van-button class="toolbar-cancel" size="small" @click="clearAddSubClassify">清空</van-button>
|
||||||
|
<van-button class="toolbar-add" size="small" type="primary" @click="showAddSubClassify = true">新增</van-button>
|
||||||
|
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmAddSubClassify">确认</van-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-picker>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 底部浮动搜索框 -->
|
||||||
|
<div class="floating-search">
|
||||||
|
<van-search
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索交易摘要、来源、卡号、分类或子分类"
|
||||||
|
@update:model-value="onSearchChange"
|
||||||
|
@clear="onSearchClear"
|
||||||
|
shape="round"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { showToast, showConfirmDialog } from 'vant'
|
||||||
|
import {
|
||||||
|
getTransactionList,
|
||||||
|
getTransactionDetail,
|
||||||
|
createTransaction,
|
||||||
|
deleteTransaction
|
||||||
|
} from '@/api/transactionRecord'
|
||||||
|
import { getCategoryTree } from '@/api/transactionCategory'
|
||||||
|
import TransactionList from '@/components/TransactionList.vue'
|
||||||
|
import TransactionDetail from '@/components/TransactionDetail.vue'
|
||||||
|
|
||||||
|
const transactionList = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const finished = ref(false)
|
||||||
|
const lastId = ref(null)
|
||||||
|
const lastTime = ref(null)
|
||||||
|
const total = ref(0)
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const currentTransaction = ref(null)
|
||||||
|
|
||||||
|
// 搜索相关
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
let searchTimer = null
|
||||||
|
|
||||||
|
// 新增交易弹窗相关
|
||||||
|
const addDialogVisible = ref(false)
|
||||||
|
const addSubmitting = ref(false)
|
||||||
|
const showDateTimePicker = ref(false)
|
||||||
|
const dateTimeValue = ref([new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()])
|
||||||
|
const showAddTypePicker = ref(false)
|
||||||
|
const showAddClassifyPicker = ref(false)
|
||||||
|
const showAddSubClassifyPicker = ref(false)
|
||||||
|
const addClassifyPickerRef = ref(null)
|
||||||
|
const addSubClassifyPickerRef = ref(null)
|
||||||
|
|
||||||
|
// 交易类型
|
||||||
|
const typeColumns = [
|
||||||
|
{ text: '支出', value: 0 },
|
||||||
|
{ text: '收入', value: 1 },
|
||||||
|
{ text: '不计入收支', value: 2 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 分类相关
|
||||||
|
const classifyColumns = ref([])
|
||||||
|
const subClassifyColumns = ref([])
|
||||||
|
|
||||||
|
// 新增表单
|
||||||
|
const addForm = reactive({
|
||||||
|
occurredAt: '',
|
||||||
|
reason: '',
|
||||||
|
amount: '',
|
||||||
|
type: 0,
|
||||||
|
typeText: '',
|
||||||
|
classify: '',
|
||||||
|
subClassify: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载分类列表(从分类树中提取)
|
||||||
|
const loadClassifyList = async (type = null) => {
|
||||||
|
try {
|
||||||
|
const response = await getCategoryTree(type)
|
||||||
|
if (response.success) {
|
||||||
|
// 从树形结构中提取分类名称(Level 2)
|
||||||
|
classifyColumns.value = (response.data || []).map(item => ({
|
||||||
|
text: item.name,
|
||||||
|
value: item.name,
|
||||||
|
id: item.id,
|
||||||
|
children: item.children || []
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类列表出错:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载子分类列表(根据选中的分类)
|
||||||
|
const loadSubClassifyList = async (classifyName) => {
|
||||||
|
try {
|
||||||
|
// 从已加载的分类树中查找对应的子分类
|
||||||
|
const classifyItem = classifyColumns.value.find(item => item.value === classifyName)
|
||||||
|
if (classifyItem && classifyItem.children) {
|
||||||
|
subClassifyColumns.value = classifyItem.children.map(child => ({
|
||||||
|
text: child.name,
|
||||||
|
value: child.name,
|
||||||
|
id: child.id
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
subClassifyColumns.value = []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载子分类列表出错:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async (isRefresh = false) => {
|
||||||
|
if (isRefresh) {
|
||||||
|
lastId.value = null
|
||||||
|
lastTime.value = null
|
||||||
|
transactionList.value = []
|
||||||
|
finished.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {}
|
||||||
|
if (lastTime.value && lastId.value) {
|
||||||
|
params.lastOccurredAt = lastTime.value
|
||||||
|
params.lastId = lastId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加搜索关键词
|
||||||
|
if (searchKeyword.value) {
|
||||||
|
params.searchKeyword = searchKeyword.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getTransactionList(params)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
const newList = response.data || []
|
||||||
|
total.value = response.total || 0
|
||||||
|
const newLastId = response.lastId || 0
|
||||||
|
const newLastTime = response.lastTime
|
||||||
|
|
||||||
|
if (isRefresh) {
|
||||||
|
transactionList.value = newList
|
||||||
|
} else {
|
||||||
|
transactionList.value = [...(transactionList.value || []), ...newList]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newLastId > 0 && newLastTime) {
|
||||||
|
lastId.value = newLastId
|
||||||
|
lastTime.value = newLastTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newList.length === 0 || newList.length < 20) {
|
||||||
|
finished.value = true
|
||||||
|
} else {
|
||||||
|
finished.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '加载数据失败')
|
||||||
|
finished.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载数据出错:', error)
|
||||||
|
showToast('加载数据出错: ' + (error.message || '未知错误'))
|
||||||
|
finished.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
const onRefresh = () => {
|
||||||
|
finished.value = false
|
||||||
|
lastId.value = null
|
||||||
|
transactionList.value = []
|
||||||
|
loadData(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索相关方法
|
||||||
|
const onSearchChange = () => {
|
||||||
|
// 防抖处理,用户停止输入500ms后自动搜索
|
||||||
|
if (searchTimer) {
|
||||||
|
clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
onSearch()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSearch = () => {
|
||||||
|
// 重置分页状态并刷新数据
|
||||||
|
lastId.value = null
|
||||||
|
lastTime.value = null
|
||||||
|
transactionList.value = []
|
||||||
|
finished.value = false
|
||||||
|
loadData(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSearchClear = () => {
|
||||||
|
searchKeyword.value = ''
|
||||||
|
onSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
const onLoad = () => {
|
||||||
|
loadData(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const viewDetail = async (transaction) => {
|
||||||
|
try {
|
||||||
|
const response = await getTransactionDetail(transaction.id)
|
||||||
|
if (response.success) {
|
||||||
|
currentTransaction.value = response.data
|
||||||
|
detailVisible.value = true
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '获取详情失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取详情出错:', error)
|
||||||
|
showToast('获取详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详情保存后的回调
|
||||||
|
const onDetailSave = async () => {
|
||||||
|
loadData(true)
|
||||||
|
// 重新加载分类列表
|
||||||
|
await loadClassifyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = async (transaction) => {
|
||||||
|
try {
|
||||||
|
await showConfirmDialog({
|
||||||
|
title: '提示',
|
||||||
|
message: '确定要删除这条交易记录吗?',
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await deleteTransaction(transaction.id)
|
||||||
|
if (response.success) {
|
||||||
|
showToast('删除成功')
|
||||||
|
loadData(true)
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('删除出错:', error)
|
||||||
|
showToast('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开新增弹窗
|
||||||
|
const openAddDialog = () => {
|
||||||
|
// 重置表单
|
||||||
|
addForm.occurredAt = ''
|
||||||
|
addForm.reason = ''
|
||||||
|
addForm.amount = ''
|
||||||
|
addForm.type = 0
|
||||||
|
addForm.typeText = ''
|
||||||
|
addForm.classify = ''
|
||||||
|
addForm.subClassify = ''
|
||||||
|
|
||||||
|
// 设置默认日期时间为当前时间
|
||||||
|
const now = new Date()
|
||||||
|
dateTimeValue.value = [now.getFullYear(), now.getMonth() + 1, now.getDate()]
|
||||||
|
addForm.occurredAt = formatDateForSubmit(now)
|
||||||
|
|
||||||
|
addDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期用于提交
|
||||||
|
const formatDateForSubmit = (date) => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期时间选择确认
|
||||||
|
const onDateTimeConfirm = ({ selectedValues }) => {
|
||||||
|
const date = new Date(selectedValues[0], selectedValues[1] - 1, selectedValues[2])
|
||||||
|
addForm.occurredAt = formatDateForSubmit(date)
|
||||||
|
showDateTimePicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增交易 - 交易类型选择确认
|
||||||
|
const onAddTypeConfirm = ({ selectedValues, selectedOptions }) => {
|
||||||
|
addForm.type = selectedValues[0]
|
||||||
|
addForm.typeText = selectedOptions[0].text
|
||||||
|
showAddTypePicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增交易 - 交易分类选择确认
|
||||||
|
const onAddClassifyConfirm = async ({ selectedOptions }) => {
|
||||||
|
if (selectedOptions && selectedOptions[0]) {
|
||||||
|
addForm.classify = selectedOptions[0].text
|
||||||
|
// 加载对应的子分类
|
||||||
|
await loadSubClassifyList(selectedOptions[0].value)
|
||||||
|
}
|
||||||
|
showAddClassifyPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增交易 - 交易子分类选择确认
|
||||||
|
const onAddSubClassifyConfirm = ({ selectedOptions }) => {
|
||||||
|
if (selectedOptions && selectedOptions[0]) {
|
||||||
|
addForm.subClassify = selectedOptions[0].text
|
||||||
|
}
|
||||||
|
showAddSubClassifyPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增交易 - 清空分类
|
||||||
|
const clearAddClassify = () => {
|
||||||
|
addForm.classify = ''
|
||||||
|
showAddClassifyPicker.value = false
|
||||||
|
showToast('已清空分类')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增交易 - 清空子分类
|
||||||
|
const clearAddSubClassify = () => {
|
||||||
|
addForm.subClassify = ''
|
||||||
|
showAddSubClassifyPicker.value = false
|
||||||
|
showToast('已清空子分类')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增交易 - 确认分类(从 picker 中获取选中值)
|
||||||
|
const confirmAddClassify = () => {
|
||||||
|
if (addClassifyPickerRef.value) {
|
||||||
|
const selectedValues = addClassifyPickerRef.value.getSelectedOptions()
|
||||||
|
if (selectedValues && selectedValues[0]) {
|
||||||
|
addForm.classify = selectedValues[0].text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showAddClassifyPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增交易 - 确认子分类(从 picker 中获取选中值)
|
||||||
|
const confirmAddSubClassify = () => {
|
||||||
|
if (addSubClassifyPickerRef.value) {
|
||||||
|
const selectedValues = addSubClassifyPickerRef.value.getSelectedOptions()
|
||||||
|
if (selectedValues && selectedValues[0]) {
|
||||||
|
addForm.subClassify = selectedValues[0].text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showAddSubClassifyPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交新增交易
|
||||||
|
const onAddSubmit = async () => {
|
||||||
|
try {
|
||||||
|
addSubmitting.value = true
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
occurredAt: addForm.occurredAt,
|
||||||
|
reason: addForm.reason,
|
||||||
|
amount: parseFloat(addForm.amount),
|
||||||
|
type: addForm.type,
|
||||||
|
classify: addForm.classify || null,
|
||||||
|
subClassify: addForm.subClassify || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await createTransaction(data)
|
||||||
|
if (response.success) {
|
||||||
|
showToast('添加成功')
|
||||||
|
addDialogVisible.value = false
|
||||||
|
loadData(true)
|
||||||
|
// 重新加载分类列表
|
||||||
|
await loadClassifyList()
|
||||||
|
} else {
|
||||||
|
showToast(response.message || '添加失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加出错:', error)
|
||||||
|
showToast('添加失败: ' + (error.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
addSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadClassifyList()
|
||||||
|
// 不需要手动调用 loadData,van-list 会自动触发 onLoad
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import '@/styles/common.css';
|
||||||
|
|
||||||
|
.floating-search {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 50px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 999;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-search :deep(.van-search) {
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-toolbar {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #ebedf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-cancel {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-confirm {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
Web/vite.config.js
Normal file
18
Web/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
84
WebApi/Controllers/BillImportController.cs
Normal file
84
WebApi/Controllers/BillImportController.cs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 账单导入控制器
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]/[action]")]
|
||||||
|
public class BillImportController(
|
||||||
|
ILogger<BillImportController> logger,
|
||||||
|
IImportService importService
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 上传账单文件
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="file">账单文件</param>
|
||||||
|
/// <param name="type">账单类型(Alipay | WeChat)</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse<object>> UploadFile(
|
||||||
|
[FromForm] IFormFile file,
|
||||||
|
[FromForm] string type
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 验证参数
|
||||||
|
if (file.Length == 0)
|
||||||
|
{
|
||||||
|
return BaseResponse<object>.Fail("请选择要上传的文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(type) || (type != "Alipay" && type != "WeChat"))
|
||||||
|
{
|
||||||
|
return BaseResponse<object>.Fail("账单类型参数错误,必须是 Alipay 或 WeChat");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
var allowedExtensions = new[] { ".csv", ".xlsx", ".xls" };
|
||||||
|
var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||||
|
if (!allowedExtensions.Contains(fileExtension))
|
||||||
|
{
|
||||||
|
return BaseResponse<object>.Fail("只支持 CSV 或 Excel 文件格式");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小(10MB限制)
|
||||||
|
const long maxFileSize = 10 * 1024 * 1024;
|
||||||
|
if (file.Length > maxFileSize)
|
||||||
|
{
|
||||||
|
return BaseResponse<object>.Fail("文件大小不能超过 10MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成唯一文件名
|
||||||
|
var fileName = $"{type}_{DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}{fileExtension}";
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
var ok = false;
|
||||||
|
var message = string.Empty;
|
||||||
|
await using (var stream = new MemoryStream())
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream);
|
||||||
|
if (type == "Alipay")
|
||||||
|
{
|
||||||
|
(ok, message) = await importService.ImportAlipayAsync(stream, fileExtension);
|
||||||
|
}
|
||||||
|
else if (type == "WeChat")
|
||||||
|
{
|
||||||
|
(ok, message) = await importService.ImportWeChatAsync(stream, fileExtension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BaseResponse<object>
|
||||||
|
{
|
||||||
|
Success = ok,
|
||||||
|
Message = message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "文件上传失败,类型: {Type}", type);
|
||||||
|
return BaseResponse<object>.Fail($"文件上传失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
WebApi/Controllers/Dto/BaseResponse.cs
Normal file
41
WebApi/Controllers/Dto/BaseResponse.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
namespace WebApi.Controllers.Dto;
|
||||||
|
|
||||||
|
public class BaseResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否成功
|
||||||
|
/// </summary>
|
||||||
|
public bool Success { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误消息
|
||||||
|
/// </summary>
|
||||||
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
public static BaseResponse Fail(string message)
|
||||||
|
{
|
||||||
|
return new BaseResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BaseResponse<T> : BaseResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 返回数据
|
||||||
|
/// </summary>
|
||||||
|
public T? Data { get; set; }
|
||||||
|
|
||||||
|
public new static BaseResponse<T> Fail(string message)
|
||||||
|
{
|
||||||
|
return new BaseResponse<T>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
40
WebApi/Controllers/Dto/EmailMessageDto.cs
Normal file
40
WebApi/Controllers/Dto/EmailMessageDto.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
namespace WebApi.Controllers.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 邮件消息DTO,包含额外的统计信息
|
||||||
|
/// </summary>
|
||||||
|
public class EmailMessageDto
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
public string From { get; set; } = string.Empty;
|
||||||
|
public string Body { get; set; } = string.Empty;
|
||||||
|
public string HtmlBody { get; set; } = string.Empty;
|
||||||
|
public DateTime ReceivedDate { get; set; }
|
||||||
|
public DateTime CreateTime { get; set; }
|
||||||
|
public DateTime? UpdateTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已解析的账单数量
|
||||||
|
/// </summary>
|
||||||
|
public int TransactionCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从实体转换为DTO
|
||||||
|
/// </summary>
|
||||||
|
public static EmailMessageDto FromEntity(Entity.EmailMessage entity, int transactionCount = 0)
|
||||||
|
{
|
||||||
|
return new EmailMessageDto
|
||||||
|
{
|
||||||
|
Id = entity.Id,
|
||||||
|
Subject = entity.Subject,
|
||||||
|
From = entity.From,
|
||||||
|
Body = entity.Body,
|
||||||
|
HtmlBody = entity.HtmlBody,
|
||||||
|
ReceivedDate = entity.ReceivedDate,
|
||||||
|
CreateTime = entity.CreateTime,
|
||||||
|
UpdateTime = entity.UpdateTime,
|
||||||
|
TransactionCount = transactionCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
WebApi/Controllers/Dto/PagedResponse.cs
Normal file
25
WebApi/Controllers/Dto/PagedResponse.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace WebApi.Controllers.Dto;
|
||||||
|
|
||||||
|
public class PagedResponse<T> : BaseResponse<T[]>
|
||||||
|
{
|
||||||
|
public long LastId { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最后一条记录的时间(用于游标分页)
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 总记录数
|
||||||
|
/// </summary>
|
||||||
|
public int Total { get; set; }
|
||||||
|
|
||||||
|
public new static PagedResponse<T> Fail(string message)
|
||||||
|
{
|
||||||
|
return new PagedResponse<T>
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
161
WebApi/Controllers/EmailMessageController.cs
Normal file
161
WebApi/Controllers/EmailMessageController.cs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
namespace WebApi.Controllers.EmailMessage;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]/[action]")]
|
||||||
|
public class EmailMessageController(
|
||||||
|
IEmailMessageRepository emailRepository,
|
||||||
|
ITransactionRecordRepository transactionRepository,
|
||||||
|
ILogger<EmailMessageController> logger,
|
||||||
|
IEmailHandleService emailHandleService,
|
||||||
|
IEmailBackgroundService emailBackgroundService
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取邮件列表(分页)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<PagedResponse<EmailMessageDto>> GetListAsync(
|
||||||
|
[FromQuery] DateTime? lastReceivedDate = null,
|
||||||
|
[FromQuery] long? lastId = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (list, lastTime, lastIdResult) = await emailRepository.GetPagedListAsync(lastReceivedDate, lastId);
|
||||||
|
var total = await emailRepository.GetTotalCountAsync();
|
||||||
|
|
||||||
|
// 为每个邮件获取账单数量
|
||||||
|
var emailDtos = new List<EmailMessageDto>();
|
||||||
|
foreach (var email in list)
|
||||||
|
{
|
||||||
|
var transactionCount = await transactionRepository.GetCountByEmailIdAsync(email.Id);
|
||||||
|
emailDtos.Add(EmailMessageDto.FromEntity(email, transactionCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PagedResponse<EmailMessageDto>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = emailDtos.ToArray(),
|
||||||
|
Total = (int)total,
|
||||||
|
LastId = lastIdResult,
|
||||||
|
LastTime = lastTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取邮件列表失败,时间: {LastTime}, ID: {LastId}", lastReceivedDate, lastId);
|
||||||
|
return PagedResponse<EmailMessageDto>.Fail($"获取邮件列表失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据ID获取邮件详情
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<BaseResponse<EmailMessageDto>> GetByIdAsync(long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var email = await emailRepository.GetByIdAsync(id);
|
||||||
|
if (email == null)
|
||||||
|
{
|
||||||
|
return BaseResponse<EmailMessageDto>.Fail("邮件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账单数量
|
||||||
|
var transactionCount = await transactionRepository.GetCountByEmailIdAsync(id);
|
||||||
|
var emailDto = EmailMessageDto.FromEntity(email, transactionCount);
|
||||||
|
|
||||||
|
return new BaseResponse<EmailMessageDto>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = emailDto
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取邮件详情失败,邮件ID: {EmailId}", id);
|
||||||
|
return BaseResponse<EmailMessageDto>.Fail($"获取邮件详情失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BaseResponse> DeleteByIdAsync(long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var success = await emailRepository.DeleteAsync(id);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
return new BaseResponse
|
||||||
|
{
|
||||||
|
Success = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("删除邮件失败,邮件不存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "删除邮件失败,邮件ID: {EmailId}", id);
|
||||||
|
return BaseResponse.Fail($"删除邮件失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 重新分析邮件并刷新交易记录
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse> RefreshTransactionRecordsAsync([FromQuery] long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var email = await emailRepository.GetByIdAsync(id);
|
||||||
|
if (email == null)
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("邮件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
var success = await emailHandleService.RefreshTransactionRecordsAsync(id);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
return new BaseResponse
|
||||||
|
{
|
||||||
|
Success = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("重新分析失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "重新分析邮件失败,邮件ID: {EmailId}", id);
|
||||||
|
return BaseResponse.Fail($"重新分析失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 立即同步邮件
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse> SyncEmailsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await emailBackgroundService.SyncEmailsAsync();
|
||||||
|
return new BaseResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "同步成功"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "同步邮件失败");
|
||||||
|
return BaseResponse.Fail($"同步邮件失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
301
WebApi/Controllers/TransactionCategoryController.cs
Normal file
301
WebApi/Controllers/TransactionCategoryController.cs
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]/[action]")]
|
||||||
|
public class TransactionCategoryController(
|
||||||
|
ITransactionCategoryRepository categoryRepository,
|
||||||
|
ILogger<TransactionCategoryController> logger
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取分类树(支持按类型筛选)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<BaseResponse<List<TransactionCategoryTreeDto>>> GetTreeAsync([FromQuery] TransactionType? type = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tree = await categoryRepository.GetCategoryTreeAsync(type);
|
||||||
|
return new BaseResponse<List<TransactionCategoryTreeDto>>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = tree
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取分类树失败");
|
||||||
|
return BaseResponse<List<TransactionCategoryTreeDto>>.Fail($"获取分类树失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取顶级分类列表(按类型)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<BaseResponse<List<TransactionCategory>>> GetTopLevelAsync([FromQuery] TransactionType type)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var categories = await categoryRepository.GetTopLevelCategoriesByTypeAsync(type);
|
||||||
|
return new BaseResponse<List<TransactionCategory>>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = categories
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取顶级分类失败, Type: {Type}", type);
|
||||||
|
return BaseResponse<List<TransactionCategory>>.Fail($"获取顶级分类失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取子分类列表
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<BaseResponse<List<TransactionCategory>>> GetChildrenAsync([FromQuery] long parentId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var categories = await categoryRepository.GetChildCategoriesAsync(parentId);
|
||||||
|
return new BaseResponse<List<TransactionCategory>>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = categories
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取子分类失败, ParentId: {ParentId}", parentId);
|
||||||
|
return BaseResponse<List<TransactionCategory>>.Fail($"获取子分类失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据ID获取分类详情
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<BaseResponse<TransactionCategory>> GetByIdAsync(long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var category = await categoryRepository.GetByIdAsync(id);
|
||||||
|
if (category == null)
|
||||||
|
{
|
||||||
|
return BaseResponse<TransactionCategory>.Fail("分类不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BaseResponse<TransactionCategory>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = category
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取分类详情失败, Id: {Id}", id);
|
||||||
|
return BaseResponse<TransactionCategory>.Fail($"获取分类详情失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建分类
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse<long>> CreateAsync([FromBody] CreateCategoryDto dto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 检查同名分类
|
||||||
|
var existing = await categoryRepository.GetByNameAndParentAsync(dto.Name, dto.ParentId, dto.Type);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
return BaseResponse<long>.Fail("同级已存在相同名称的分类");
|
||||||
|
}
|
||||||
|
|
||||||
|
var category = new TransactionCategory
|
||||||
|
{
|
||||||
|
Name = dto.Name,
|
||||||
|
ParentId = dto.ParentId,
|
||||||
|
Type = dto.Type,
|
||||||
|
Level = dto.Level,
|
||||||
|
SortOrder = dto.SortOrder,
|
||||||
|
Icon = dto.Icon,
|
||||||
|
Remark = dto.Remark
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await categoryRepository.AddAsync(category);
|
||||||
|
if (result)
|
||||||
|
{
|
||||||
|
return new BaseResponse<long>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = category.Id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BaseResponse<long>.Fail("创建分类失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "创建分类失败, Dto: {@Dto}", dto);
|
||||||
|
return BaseResponse<long>.Fail($"创建分类失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新分类
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse> UpdateAsync([FromBody] UpdateCategoryDto dto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var category = await categoryRepository.GetByIdAsync(dto.Id);
|
||||||
|
if (category == null)
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("分类不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果修改了名称,检查同名
|
||||||
|
if (category.Name != dto.Name)
|
||||||
|
{
|
||||||
|
var existing = await categoryRepository.GetByNameAndParentAsync(dto.Name, category.ParentId, category.Type);
|
||||||
|
if (existing != null && existing.Id != dto.Id)
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("同级已存在相同名称的分类");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
category.Name = dto.Name;
|
||||||
|
category.SortOrder = dto.SortOrder;
|
||||||
|
category.Icon = dto.Icon;
|
||||||
|
category.IsEnabled = dto.IsEnabled;
|
||||||
|
category.Remark = dto.Remark;
|
||||||
|
category.UpdateTime = DateTime.Now;
|
||||||
|
|
||||||
|
var success = await categoryRepository.UpdateAsync(category);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
return new BaseResponse { Success = true };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("更新分类失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "更新分类失败, Dto: {@Dto}", dto);
|
||||||
|
return BaseResponse.Fail($"更新分类失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除分类
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse> DeleteAsync([FromQuery] long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 检查是否有子分类
|
||||||
|
var children = await categoryRepository.GetChildCategoriesAsync(id);
|
||||||
|
if (children.Any())
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("该分类下存在子分类,无法删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否被使用
|
||||||
|
var inUse = await categoryRepository.IsCategoryInUseAsync(id);
|
||||||
|
if (inUse)
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("该分类已被使用,无法删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
var success = await categoryRepository.DeleteAsync(id);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
return new BaseResponse { Success = true };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("删除分类失败,分类不存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "删除分类失败, Id: {Id}", id);
|
||||||
|
return BaseResponse.Fail($"删除分类失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量创建分类(用于初始化)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse<int>> BatchCreateAsync([FromBody] List<CreateCategoryDto> dtoList)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var categories = dtoList.Select(dto => new TransactionCategory
|
||||||
|
{
|
||||||
|
Name = dto.Name,
|
||||||
|
ParentId = dto.ParentId,
|
||||||
|
Type = dto.Type,
|
||||||
|
Level = dto.Level,
|
||||||
|
SortOrder = dto.SortOrder,
|
||||||
|
Icon = dto.Icon,
|
||||||
|
Remark = dto.Remark
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var result = await categoryRepository.AddRangeAsync(categories);
|
||||||
|
if (result)
|
||||||
|
{
|
||||||
|
return new BaseResponse<int>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = categories.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BaseResponse<int>.Fail("批量创建分类失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "批量创建分类失败, Count: {Count}", dtoList.Count);
|
||||||
|
return BaseResponse<int>.Fail($"批量创建分类失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建分类DTO
|
||||||
|
/// </summary>
|
||||||
|
public record CreateCategoryDto(
|
||||||
|
string Name,
|
||||||
|
long ParentId,
|
||||||
|
TransactionType Type,
|
||||||
|
int Level,
|
||||||
|
int SortOrder = 0,
|
||||||
|
string? Icon = null,
|
||||||
|
string? Remark = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新分类DTO
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateCategoryDto(
|
||||||
|
long Id,
|
||||||
|
string Name,
|
||||||
|
int SortOrder,
|
||||||
|
string? Icon,
|
||||||
|
bool IsEnabled,
|
||||||
|
string? Remark
|
||||||
|
);
|
||||||
302
WebApi/Controllers/TransactionRecordController.cs
Normal file
302
WebApi/Controllers/TransactionRecordController.cs
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]/[action]")]
|
||||||
|
public class TransactionRecordController(
|
||||||
|
ITransactionRecordRepository transactionRepository,
|
||||||
|
ILogger<TransactionRecordController> logger
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取交易记录列表(分页)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<PagedResponse<Entity.TransactionRecord>> GetListAsync(
|
||||||
|
[FromQuery] DateTime? lastOccurredAt = null,
|
||||||
|
[FromQuery] long? lastId = null,
|
||||||
|
[FromQuery] string? searchKeyword = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (list, lastTime, lastIdResult) = await transactionRepository.GetPagedListAsync(lastOccurredAt, lastId, 20, searchKeyword);
|
||||||
|
var total = await transactionRepository.GetTotalCountAsync();
|
||||||
|
|
||||||
|
return new PagedResponse<Entity.TransactionRecord>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = list.ToArray(),
|
||||||
|
Total = (int)total,
|
||||||
|
LastId = lastIdResult,
|
||||||
|
LastTime = lastTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取交易记录列表失败,时间: {LastTime}, ID: {LastId}", lastOccurredAt, lastId);
|
||||||
|
return PagedResponse<Entity.TransactionRecord>.Fail($"获取交易记录列表失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据ID获取交易记录详情
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<BaseResponse<Entity.TransactionRecord>> GetByIdAsync(long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var transaction = await transactionRepository.GetByIdAsync(id);
|
||||||
|
if (transaction == null)
|
||||||
|
{
|
||||||
|
return BaseResponse<Entity.TransactionRecord>.Fail("交易记录不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BaseResponse<Entity.TransactionRecord>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = transaction
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取交易记录详情失败,交易ID: {TransactionId}", id);
|
||||||
|
return BaseResponse<Entity.TransactionRecord>.Fail($"获取交易记录详情失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据邮件ID获取交易记录列表
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{emailId}")]
|
||||||
|
public async Task<BaseResponse<List<Entity.TransactionRecord>>> GetByEmailIdAsync(long emailId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var transactions = await transactionRepository.GetByEmailIdAsync(emailId);
|
||||||
|
return new BaseResponse<List<Entity.TransactionRecord>>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = transactions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取邮件交易记录失败,邮件ID: {EmailId}", emailId);
|
||||||
|
return BaseResponse<List<Entity.TransactionRecord>>.Fail($"获取邮件交易记录失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建交易记录
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse> CreateAsync([FromBody] CreateTransactionDto dto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 解析日期字符串
|
||||||
|
if (!DateTime.TryParse(dto.OccurredAt, out var occurredAt))
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("交易时间格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
var transaction = new Entity.TransactionRecord
|
||||||
|
{
|
||||||
|
OccurredAt = occurredAt,
|
||||||
|
Reason = dto.Reason ?? string.Empty,
|
||||||
|
Amount = dto.Amount,
|
||||||
|
Type = dto.Type,
|
||||||
|
Classify = dto.Classify ?? string.Empty,
|
||||||
|
SubClassify = dto.SubClassify ?? string.Empty,
|
||||||
|
ImportFrom = "手动录入",
|
||||||
|
EmailMessageId = 0 // 手动录入的记录,EmailMessageId 设为 0
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await transactionRepository.AddAsync(transaction);
|
||||||
|
if (result)
|
||||||
|
{
|
||||||
|
return new BaseResponse
|
||||||
|
{
|
||||||
|
Success = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("创建交易记录失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "创建交易记录失败,交易信息: {@TransactionDto}", dto);
|
||||||
|
return BaseResponse.Fail($"创建交易记录失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新交易记录
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse> UpdateAsync([FromBody] UpdateTransactionDto dto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var transaction = await transactionRepository.GetByIdAsync(dto.Id);
|
||||||
|
if (transaction == null)
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("交易记录不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新可编辑字段
|
||||||
|
transaction.Reason = dto.Reason ?? string.Empty;
|
||||||
|
transaction.Amount = dto.Amount;
|
||||||
|
transaction.Balance = dto.Balance;
|
||||||
|
transaction.Type = dto.Type;
|
||||||
|
transaction.Classify = dto.Classify ?? string.Empty;
|
||||||
|
transaction.SubClassify = dto.SubClassify ?? string.Empty;
|
||||||
|
|
||||||
|
var success = await transactionRepository.UpdateAsync(transaction);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
return new BaseResponse
|
||||||
|
{
|
||||||
|
Success = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("更新交易记录失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "更新交易记录失败,交易ID: {TransactionId}, 交易信息: {@TransactionDto}", dto.Id, dto);
|
||||||
|
return BaseResponse.Fail($"更新交易记录失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除交易记录
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse> DeleteByIdAsync([FromQuery] long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var success = await transactionRepository.DeleteAsync(id);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
return new BaseResponse
|
||||||
|
{
|
||||||
|
Success = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BaseResponse.Fail("删除交易记录失败,记录不存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "删除交易记录失败,交易ID: {TransactionId}", id);
|
||||||
|
return BaseResponse.Fail($"删除交易记录失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定月份每天的消费统计
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<BaseResponse<List<DailyStatisticsDto>>> GetDailyStatisticsAsync(
|
||||||
|
[FromQuery] int year,
|
||||||
|
[FromQuery] int month
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var statistics = await transactionRepository.GetDailyStatisticsAsync(year, month);
|
||||||
|
var result = statistics.Select(s => new DailyStatisticsDto(s.Key, s.Value.count, s.Value.amount)).ToList();
|
||||||
|
|
||||||
|
return new BaseResponse<List<DailyStatisticsDto>>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = result
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取日历统计数据失败,年份: {Year}, 月份: {Month}", year, month);
|
||||||
|
return BaseResponse<List<DailyStatisticsDto>>.Fail($"获取日历统计数据失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定日期的交易记录
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<BaseResponse<List<Entity.TransactionRecord>>> GetByDateAsync(
|
||||||
|
[FromQuery] string date
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!DateTime.TryParse(date, out var targetDate))
|
||||||
|
{
|
||||||
|
return BaseResponse<List<Entity.TransactionRecord>>.Fail("日期格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当天的开始和结束时间
|
||||||
|
var startDate = targetDate.Date;
|
||||||
|
var endDate = startDate.AddDays(1);
|
||||||
|
|
||||||
|
var records = await transactionRepository.GetByDateRangeAsync(startDate, endDate);
|
||||||
|
|
||||||
|
return new BaseResponse<List<Entity.TransactionRecord>>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = records
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取指定日期的交易记录失败,日期: {Date}", date);
|
||||||
|
return BaseResponse<List<Entity.TransactionRecord>>.Fail($"获取指定日期的交易记录失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建交易记录DTO
|
||||||
|
/// </summary>
|
||||||
|
public record CreateTransactionDto(
|
||||||
|
string OccurredAt,
|
||||||
|
string? Reason,
|
||||||
|
decimal Amount,
|
||||||
|
TransactionType Type,
|
||||||
|
string? Classify,
|
||||||
|
string? SubClassify
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新交易记录DTO
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateTransactionDto(
|
||||||
|
long Id,
|
||||||
|
string? Reason,
|
||||||
|
decimal Amount,
|
||||||
|
decimal Balance,
|
||||||
|
TransactionType Type,
|
||||||
|
string? Classify,
|
||||||
|
string? SubClassify
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 日历统计响应DTO
|
||||||
|
/// </summary>
|
||||||
|
public record DailyStatisticsDto(
|
||||||
|
string Date,
|
||||||
|
int Count,
|
||||||
|
decimal Amount
|
||||||
|
);
|
||||||
6
WebApi/GlobalUsings.cs
Normal file
6
WebApi/GlobalUsings.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
global using Service;
|
||||||
|
global using Common;
|
||||||
|
global using Microsoft.AspNetCore.Mvc;
|
||||||
|
global using WebApi.Controllers.Dto;
|
||||||
|
global using Repository;
|
||||||
|
global using Entity;
|
||||||
112
WebApi/Program.cs
Normal file
112
WebApi/Program.cs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
using FreeSql;
|
||||||
|
using Scalar.AspNetCore;
|
||||||
|
using Serilog;
|
||||||
|
using Service.AppSettingModel;
|
||||||
|
using Yitter.IdGenerator;
|
||||||
|
|
||||||
|
// 初始化雪花算法ID生成器
|
||||||
|
var options = new IdGeneratorOptions(1); // WorkerId 为 1,可根据实际部署情况调整
|
||||||
|
YitIdHelper.SetIdGenerator(options);
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// 配置 Serilog
|
||||||
|
builder.Host.UseSerilog((context, loggerConfig) =>
|
||||||
|
{
|
||||||
|
loggerConfig.ReadFrom.Configuration(context.Configuration);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add services to the container.
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
|
// 配置 CORS
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddDefaultPolicy(policy =>
|
||||||
|
{
|
||||||
|
policy.WithOrigins("http://localhost:5173")
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 绑定配置
|
||||||
|
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
|
||||||
|
builder.Services.Configure<AISettings>(builder.Configuration.GetSection("OpenAI"));
|
||||||
|
|
||||||
|
// 配置 FreeSql + SQLite
|
||||||
|
var dbPath = Path.Combine(AppContext.BaseDirectory, "database");
|
||||||
|
if (!Directory.Exists(dbPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dbPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用绝对路径作为数据库文件路径
|
||||||
|
var dbFilePath = Path.Combine(dbPath, "EmailBill.db");
|
||||||
|
var connectionString = $"Data Source={dbFilePath}";
|
||||||
|
Log.Information("数据库路径: {DbPath}", dbFilePath);
|
||||||
|
|
||||||
|
var fsql = new FreeSqlBuilder()
|
||||||
|
.UseConnectionString(DataType.Sqlite, connectionString)
|
||||||
|
.UseAutoSyncStructure(true)
|
||||||
|
.UseLazyLoading(true)
|
||||||
|
.UseMonitorCommand(
|
||||||
|
cmd =>
|
||||||
|
{
|
||||||
|
Log.Information("执行SQL: {Sql}", cmd.CommandText);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(fsql);
|
||||||
|
|
||||||
|
// 自动扫描注册服务和仓储
|
||||||
|
builder.Services.AddServices();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
app.MapScalarApiReference();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用静态文件服务
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
// 启用 CORS
|
||||||
|
app.UseCors();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
// 添加 SPA 回退路由(用于前端路由)
|
||||||
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
|
// 启动后台邮件抓取服务(必须只注册一次)
|
||||||
|
app.Lifetime.ApplicationStarted.Register(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (app.Services.GetRequiredService<IEmailBackgroundService>() is not EmailBackgroundService emailService)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已在运行,避免重复启动
|
||||||
|
if (!emailService.IsBusy)
|
||||||
|
{
|
||||||
|
emailService.RunWorkerAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||||
|
logger.LogError(ex, "启动后台服务失败");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.Run();
|
||||||
23
WebApi/Properties/launchSettings.json
Normal file
23
WebApi/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5071",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7275;http://localhost:5071",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
WebApi/WebApi.csproj
Normal file
26
WebApi/WebApi.csproj
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
|
<PackageReference Include="Scalar.AspNetCore" />
|
||||||
|
<PackageReference Include="FreeSql.Provider.Sqlite" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Service\Service.csproj" />
|
||||||
|
<ProjectReference Include="..\Common\Common.csproj" />
|
||||||
|
<ProjectReference Include="..\Repository\Repository.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="wwwroot\**\*">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Watch Remove="logs/**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
WebApi/WebApi.http
Normal file
6
WebApi/WebApi.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@WebApi_HostAddress = http://localhost:5071
|
||||||
|
|
||||||
|
GET {{WebApi_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user