first commot
This commit is contained in:
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))
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user