From 7cf0a756039935165faf36fe3296b699d0bc0916 Mon Sep 17 00:00:00 2001 From: zhxiao1124 Date: Mon, 13 Apr 2026 14:22:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 38 + README.md | 45 + backend/app/__init__.py | 1 + .../app/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 170 bytes backend/app/__pycache__/auth.cpython-312.pyc | Bin 0 -> 2313 bytes .../app/__pycache__/config.cpython-312.pyc | Bin 0 -> 1919 bytes .../app/__pycache__/database.cpython-312.pyc | Bin 0 -> 1420 bytes .../app/__pycache__/init_data.cpython-312.pyc | Bin 0 -> 4705 bytes backend/app/__pycache__/main.cpython-312.pyc | Bin 0 -> 2225 bytes .../app/__pycache__/models.cpython-312.pyc | Bin 0 -> 10673 bytes backend/app/auth.py | 40 + backend/app/config.py | 38 + backend/app/database.py | 33 + backend/app/init_data.py | 80 + backend/app/main.py | 48 + backend/app/models.py | 198 ++ .../__pycache__/agents.cpython-312.pyc | Bin 0 -> 15778 bytes .../routers/__pycache__/auth.cpython-312.pyc | Bin 0 -> 6118 bytes .../__pycache__/calculate.cpython-312.pyc | Bin 0 -> 8808 bytes .../__pycache__/categories.cpython-312.pyc | Bin 0 -> 24511 bytes .../__pycache__/dashboard.cpython-312.pyc | Bin 0 -> 8292 bytes .../__pycache__/employees.cpython-312.pyc | Bin 0 -> 19330 bytes .../__pycache__/performance.cpython-312.pyc | Bin 0 -> 19087 bytes .../__pycache__/reports.cpython-312.pyc | Bin 0 -> 6252 bytes .../__pycache__/settings.cpython-312.pyc | Bin 0 -> 3902 bytes backend/app/routers/agents.py | 367 ++++ backend/app/routers/auth.py | 152 ++ backend/app/routers/calculate.py | 217 ++ backend/app/routers/categories.py | 559 +++++ backend/app/routers/dashboard.py | 172 ++ backend/app/routers/employees.py | 421 ++++ backend/app/routers/performance.py | 406 ++++ backend/app/routers/reports.py | 150 ++ backend/app/routers/settings.py | 88 + .../calculate_service.cpython-312.pyc | Bin 0 -> 23876 bytes .../report_service.cpython-312.pyc | Bin 0 -> 15251 bytes backend/app/services/calculate_service.py | 604 +++++ backend/app/services/report_service.py | 369 ++++ backend/init_db.py | 39 + backend/requirements.txt | 13 + backend/sales_management.db | Bin 0 -> 90112 bytes frontend/index.html | 13 + frontend/package-lock.json | 1938 +++++++++++++++++ frontend/package.json | 26 + frontend/src/App.vue | 25 + frontend/src/api/agents.js | 44 + frontend/src/api/auth.js | 34 + frontend/src/api/calculate.js | 53 + frontend/src/api/categories.js | 62 + frontend/src/api/dashboard.js | 18 + frontend/src/api/employees.js | 53 + frontend/src/api/performance.js | 45 + frontend/src/api/settings.js | 26 + frontend/src/components/Header.vue | 117 + frontend/src/components/Layout.vue | 37 + frontend/src/components/Sidebar.vue | 74 + frontend/src/main.js | 22 + frontend/src/router/index.js | 87 + frontend/src/store/index.js | 3 + frontend/src/store/modules/app.js | 24 + frontend/src/store/modules/user.js | 39 + frontend/src/utils/auth.js | 17 + frontend/src/utils/request.js | 72 + frontend/src/views/Dashboard.vue | 360 +++ frontend/src/views/Login.vue | 167 ++ frontend/src/views/agents/AgentForm.vue | 225 ++ frontend/src/views/agents/AgentList.vue | 268 +++ .../src/views/calculate/CalculateCompany.vue | 255 +++ .../src/views/calculate/CalculateEmployee.vue | 270 +++ .../src/views/categories/CategoryForm.vue | 205 ++ .../src/views/categories/CategoryImport.vue | 610 ++++++ .../src/views/categories/CategoryList.vue | 289 +++ .../src/views/employees/EmployeeDetail.vue | 51 + frontend/src/views/employees/EmployeeForm.vue | 234 ++ frontend/src/views/employees/EmployeeList.vue | 267 +++ .../src/views/performance/PerformanceList.vue | 357 +++ frontend/src/views/settings/Settings.vue | 215 ++ frontend/vite.config.js | 22 + 78 files changed, 10702 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/__pycache__/auth.cpython-312.pyc create mode 100644 backend/app/__pycache__/config.cpython-312.pyc create mode 100644 backend/app/__pycache__/database.cpython-312.pyc create mode 100644 backend/app/__pycache__/init_data.cpython-312.pyc create mode 100644 backend/app/__pycache__/main.cpython-312.pyc create mode 100644 backend/app/__pycache__/models.cpython-312.pyc create mode 100644 backend/app/auth.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/init_data.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routers/__pycache__/agents.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/auth.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/calculate.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/categories.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/dashboard.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/employees.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/performance.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/reports.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/settings.cpython-312.pyc create mode 100644 backend/app/routers/agents.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/calculate.py create mode 100644 backend/app/routers/categories.py create mode 100644 backend/app/routers/dashboard.py create mode 100644 backend/app/routers/employees.py create mode 100644 backend/app/routers/performance.py create mode 100644 backend/app/routers/reports.py create mode 100644 backend/app/routers/settings.py create mode 100644 backend/app/services/__pycache__/calculate_service.cpython-312.pyc create mode 100644 backend/app/services/__pycache__/report_service.cpython-312.pyc create mode 100644 backend/app/services/calculate_service.py create mode 100644 backend/app/services/report_service.py create mode 100644 backend/init_db.py create mode 100644 backend/requirements.txt create mode 100644 backend/sales_management.db create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/agents.js create mode 100644 frontend/src/api/auth.js create mode 100644 frontend/src/api/calculate.js create mode 100644 frontend/src/api/categories.js create mode 100644 frontend/src/api/dashboard.js create mode 100644 frontend/src/api/employees.js create mode 100644 frontend/src/api/performance.js create mode 100644 frontend/src/api/settings.js create mode 100644 frontend/src/components/Header.vue create mode 100644 frontend/src/components/Layout.vue create mode 100644 frontend/src/components/Sidebar.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/store/index.js create mode 100644 frontend/src/store/modules/app.js create mode 100644 frontend/src/store/modules/user.js create mode 100644 frontend/src/utils/auth.js create mode 100644 frontend/src/utils/request.js create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/Login.vue create mode 100644 frontend/src/views/agents/AgentForm.vue create mode 100644 frontend/src/views/agents/AgentList.vue create mode 100644 frontend/src/views/calculate/CalculateCompany.vue create mode 100644 frontend/src/views/calculate/CalculateEmployee.vue create mode 100644 frontend/src/views/categories/CategoryForm.vue create mode 100644 frontend/src/views/categories/CategoryImport.vue create mode 100644 frontend/src/views/categories/CategoryList.vue create mode 100644 frontend/src/views/employees/EmployeeDetail.vue create mode 100644 frontend/src/views/employees/EmployeeForm.vue create mode 100644 frontend/src/views/employees/EmployeeList.vue create mode 100644 frontend/src/views/performance/PerformanceList.vue create mode 100644 frontend/src/views/settings/Settings.vue create mode 100644 frontend/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42b9276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo + +.eslintcache + +# Cypress +/cypress/videos/ +/cypress/screenshots/ + +# Vitest +__screenshots__/ +/.trae/documents +/.trae/specs \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6bb6a4 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# 销售业绩与收益计算管理系统 + +## 项目结构 + +``` +sales-management-system/ +├── backend/ # FastAPI 后端 +├── frontend/ # Vue3 前端 +└── docs/ # 项目文档 +``` + +## 技术栈 + +- **后端**: FastAPI + SQLite3 +- **前端**: Vue 3 + Element Plus + ECharts +- **认证**: JWT + +## 开发文档 + +- [PRD产品需求文档](docs/01-PRD.md) +- [数据库设计文档](docs/02-Database.md) +- [API接口文档](docs/03-API.md) +- [开发计划文档](docs/04-Development-Plan.md) + +## 快速开始 + +### 后端启动 +```bash +cd backend +pip install -r requirements.txt +python init_db.py +uvicorn app.main:app --reload +``` + +### 前端启动 +```bash +cd frontend +npm install +npm run dev +``` + +## 默认账号 + +- 用户名: admin +- 密码: admin123 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8dc179e --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# 使app目录成为Python包 diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a279581739a045431b0b3891e8478744567a68bd GIT binary patch literal 170 zcmZ9CJqp4=6b8RF3L@kR_7=&zfY{r3h2@z%LP++9-Ib7&SXz4tFW@mefuu_%X`N~Y z=AGv|0C+U7b`RjQ#6Qy-z#StF>heTb0hpF&K2vZmQR%$cs^@_Q7rN-V5{leQEDT$2 v#E4Br9AgqgSFSp-HL>@kb!*$Cb?L`>do-@T^>PXu017}{?9U^UliB&NrOBynqoTQ z6}&Oe{*m1|yhPSTQ4# z2w8@0r%Z}@<%D5R5d0*dQo@on5iUUC0HOeZ3;-ML4hgIP9zaw?w^#-;s0idB=qOnZ z01v-Czkc=X+SLyq%%9aIu30G@3g_$K>U)Gn#?rb(D*%920)Q<}Z)|)V=%A*_I@(Q( zB*HNx)sZskY1=Zwq{B91#NHb>62>@*lLXsqr)@^!9ivA0G)dqN!?HSzB%5fr(!4T% zLa9i)4FEK8j#x~=eXVX)+nLpN&Lu9JH?*#0rE5b}#oa}m!e~>KqG$(>m!1iZg~GcI z7&QQ}{>cw(pMIVPW9_qgwMOa}@oGVI@h%~eM0Wr{l}M|7 zQbI#kYnbc3p|vb4Ewl~*`niG)W^f;wv?7$)AxCCUaqB7;a}bFJ%RqEX zIuaP+p6<5}9UJHy>OHEvXoKjt=S8ZAnV~|qn%4@~@;(klQ_L1BX%v>%V{MocgOQLC z4inoBG4nJ@P|*#dZrgSgd9TVp>$&LpOq=SvRp!54y?u7q#a%b5chA3@t=@M|ylV7qO{zu**3f9;-0yYMfYmk{%qU+ z>yxX8MzV)SR%(J%M{a6$%SxT2cK3j6 zHt&{%`Xp-{EuxYfPzDV`6R?tun}Ny*Mj$PvU{W4|6w>9SkmmZ=Up<&Vdw6)LBol8* z7Iwd`78u1uFiM*QaW9HTn|O#z>Jr^6m@Ko5bj&nxVT9)SD$l=HAZi}~>=r~FaEQt& zipyUF`1qyX)#lD@bLaIFtAXKcV0ih>6Du_%Gi5g`>t-tcyRFs!4anucRau*pk<0(s z7MK6vmDk`$Pq$0{O+no*Sx5c4-_dYhyDCdtk;FI^Ja8py+QhkWzNAVq)?$&-_QKx* z6_|3bVOb(F78$1=QKD0^Gb3f2QLc=dX3QDm&LHFpj)a+`Kq{*H1{^Us#Xokx5jSx% zM!M*J01(vM9|J&6MhM-7wmZ;p7hbp{7EOO@^&fRjxo=hMnD3o`eW`V&qI*iqsS-Lc z-;)Cg9aw5|etvD<5MItJq1Jg>gjz4lBGmfG>qYf*I0s%-|J|!gec6`}I_u?u;~OHD Z+o2-=oSXv{`RCo2{a;r6BbLr}{|6#xAnyPG literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96435fa8b049ee1b2fcdf21ec7a6940fe7891a2b GIT binary patch literal 1919 zcmai#&uNC-b#++dp@1+@r~Z0sd*@vr)a1bZ1RyAxw!@0#5; z$Y~Fj8md%1lmikiht|CnMWTmRDisp_1KLWKe6dzSqJk?~sQfB)e2K5Pd|gjykB_rG3$jVl#Io{Q&h&A1cflxQMOH2n zr9GB?P#d-!9F2s4#Hk=TvY>L3lOV#Yj#B`jIwcqBBD{(i@sgYMxfpdx-HhCfx}_dQ zJ&by#Ug9CV>S5F;^^qNfS9dV#m--p)WHcb{WHiWVNE%=?%xFX!WHicXOd4Xei_y3= z%*e}VLKYq36 zJidEt!C6vl%g+WBZ%`e%>Ani)VyvG=tMZR7jo2ESOj%-cpo)dXqvp*Pi2xdE5KS97!>C zCXtE>@sQXOL1fvnd97}}7 zXztcmwEaA1?|zLc6isBqY#+IghVLx?vGU`bJ^dP8Oo+sBGL;BtLz%X6b&1EAJ;j4JK06h zIezr;RUAI&^ZrgT212^FoX@SG0MV|*eaLoWtjR@!vE74lQCG_a>U%K0T$T&%9yigh z;N^UQ+=eZ>(DTKhq3c#KpqGfI6y&SHtGaQ?EXfLK6|Dbd!TQaLX^~>^f~;I3ni`Z# zrJ%xM2$U+es}+th4gh-q!0ZBmRcH>4f95u(0l=66fVgkm@KTtdW|A7Jvz?}8*!>vG znxZn|LnWHowy zUjx0r>4LEZuKK}sX9Ee%k;!Ug&1|5_=G%cfnyw8sP+-#qZylx;>H)n%&G*dg?K*6T}NFD8~WgFvL=duS%6;;-(yz+7gUD@tlE^CUV z>jl%G4^C?(I@v3~Et->0lf17rk}YcIq%xX&tQ`CK0OVN U-cKf;Iyi20%L$G?=CNr01^Vsy)&Kwi literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/database.cpython-312.pyc b/backend/app/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d81f80991cd62671ebf7d15d497a2ae4bcbb9065 GIT binary patch literal 1420 zcmah|U1(fI6h3qB?7eqyHk&_dni``JRIZY&`yfV;LP8snriHfl!S!L8+&kI5oBOk! zneBF=;wlAOV;fOyKqwRxL{fiVL=h3+eMzvAFi@(Pl=_yiK9tf2nf=K|5FD60-#Pbi z=6v6r^Gk294FGVD{^TPa;7?_AmeB@{hXAkw9(X8#5am#)PMVTL`!Gkp{xAE;eA z)QRYxH?38@W#!TuP{T7llcsMb_q=@?P`p=Pzh_f(LGyYp;@p6jx(Kg#*Qv+QW3EmhoLWI zQsIT5!iy5m`;jLNMn#21ZdU^U3;^Q*&=3G{3=phM;R0OKRuF;#suU{FSs~Hx@A~PA6Y2*f^nyJQYk(Y@}Gn@pJPlO~Q1sc*w zOz;{PG|bEscacV3hLp=0j|igPWwKn8hSJ}gXQ~I)=8mci?$==}d0=U*KC`3giJ{GO z|9jT&mc49UwyxOsFKI3CkUs(d@AOQet9Tqf9GF6%4!)p&t52bS39iIbyD`_C9VL5f zm9!4(o~1+=caZ2zAKXNpZbwkl9KA9K0NacrsJ{u}Qf+>BiB5 zUtpGUSbIZ~Ix_v*^vv|>qxmzZ=A_L9VS-nEQIv@zM`zBw!X8sulrZd|`lx4RiVYi8 zA60Ub>I3WLF!m||Ji*^TVU_3X*DQ@LB&H`WH9x=~plm{=q3W^!P8?DE*(CbqS@v73he q(HoWT6F<-1>N{3X?HXVm{M8((;~m35Lm!w8FwjtAP(#l)3;YN8qFvVj literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/init_data.cpython-312.pyc b/backend/app/__pycache__/init_data.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..84a52b03b041b5daa561e1778b70eb1c3be30ec0 GIT binary patch literal 4705 zcmb7IZ*UXG72lIi(&?Z7Ai!X35VkRhU^~I2VQ3qo_lg!PbJIgXUowz#% zlt>vf2}BGgHc3cGaT92in$#|VmKj_dGX2<@evw9`Qg+B>()uJk{SZyUr+n({NhdkB z+%`KN?c06t_x9cHdvD+R?`E?B003r3zHonI2H;<4pfo&3{NMoqd;n-bV;;a_!x)BW zl}E*@ht(`TjI)|yjXbCJXj$E`PNs1W!Rm+g7*GL5PitHlt)0`&sGgNiHMT{e3cZ4%4YY9tr%gq1l?8c)%Hzy3uB0fg zN|7$~d0eTCD=UiIUXWL)Jg!{EZ77PfD$<2MkF(GlM{s&mQCv+yUZL{1%`&c{D6USC zF7$ca78$p-D6YOBuTXhhCB1C~r>lzM_9)VYK98%GaodaH_7>z7Dv#SCenDH@%5sj)^yzR(BN|95oif;t|X=oK3jbWk0{gPNdr zZs%++p@xWc;%-G$OmO(miM<+Sk1*f@bnWl3kUpqy1uy#mcu505$PhAyOd)fqWSu>F zRIygkQ*@8=Qe*oS`9dEw{>(jch>t1gpdn}snu6wF360Oy&F1oBsC1ouDpjn+zy$xZ z_GwoE8K}4I_M^-rg?}r}oRcQrUtYZQ;Cdu=@kZ+Wb*Q0e*6oFa+ x*^d%s`p=8$ z#gEh1ucoKYNEa?GqjYc6K*BFDyq97bX!cQpaDwA$a+DHAA%pzxOOv;y+kcY6m!-u8Kgz3Ct2O=JwN&`FGtl zLTwha3QL_VijBEZmeykWAIr3GYg`s#RbEfp(} zEamo+zH9;FM>o~d{9@|uYem-HO1T)XNHVO?!vz?IEGWrZe&LdI`_rX`iS)wE(&8uS zsWU&ZTqC35=ByGQ&yBc6QW&N9>=ExoE3d#x;WJBjKgoV|{ysgwTyg_aN8hC`E+ zV||o2Kq5Dmtxlhsm2TdY?p#d0`*v#n%JQjqrK#}J-RY$}A1>Xw(qz#SinSddnf#x&Sm3yu|gJ4^jKe zVOKV9Mf2FLt6ZQVefd`Eoj*#E8EHNY%Vc-)xL;vNmJ&pUPrtVDddIh~-a4Efk=s8G z^Qg$OY1a5sgA~5Jba&d0oCuc7dYn8%i3};b1dnbR9$i>j1zH{@G>AJnnt`PSUn6-+ zWMB!)dBssrfaIB@Xk6+R$d#VVqv~r_e#*ME6h4!_xwvw(du^3abKKAH0Uq6IP&?xG zhz!r8{fC+nH!q0L=sQ7^&RqG8%L!3U(KOV`hKW$)n*Y9W@Y8iw(6#;ukTkjy* z*3;MRc%iM0EUX2$|5T)}-_hPRNDg$``?H_^U-|auc~sEzs8Zw6)yboK+E&J+#{iG6 zEm%e~Bb47$cpX7Cia$*@?r;=$P$LH%B$$(KL4INBaz_F;C}{LXa&f$y5oAA;*Ws(> zUDsFedl!p--SjtPe z7?$yh4MIQ=8Mf&ts~&k5N4>L=Glu+heuu z@f{t}u94`dFG?Iw>UYms=AKV9496OVtECp-SqvN7RwdL|tdB zt}|}!iavWR$_Aput4X~zQX3gf)OW|~yW=%IQ3n@29*PpLC-pUvmN_D^r!TgrFJ5;f zI>1J`*P=u)sjrJT=k_G_4#xHl#_NZo&z;Bs@G54PmwH;2)bETmM@}Yo_r`Yj#%mqX zBVzQGtj6^i#89Tfv_%`mH)g6p-GT3cUQ-n#c7)a8p{dqOyONeo9~``JaJnOIsY_TM ziCG?*>qzW79NTyJug$T2?Qu&-!qOA7^u#TWFab*|?r*M6R_#b`uT552liREh^_q&3 za7V_3&b<9>`_vQX9T|&WQ zMoo2$XbR(Ddg_@g+G{$+7DwE&FJWnpS(`Gd9Co8r*H0!pMggY~3ibV&%G*x9PjGF39OL9?FCMNfBt|c$P;merTY4zDBw75#+?14Sf{#S(cOYj<{XC4o#GzV$|b4+L+71 zJb@Cz6X@sH=m$l~XMi6<^Z)?hN8(p3NBcd@ll+SS094!xNIIj&Fzjnk^9^`B1|I(! s)P4>2d|Og^rSr<6`TBb$_HpA^#)@-KjuRPeJBB@aY4<~bdRg&*0H2_7tpET3 literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7dedbc024a27d8ee8f4e7aa2d06f566b7663b516 GIT binary patch literal 2225 zcmbVMU1%It6h3!mc4q&x-3g6tDpb>8r_$ZlmR4;W2~wdCjluTCd0B4f-t0~?voqW~ z+n7E@lqw36LSaD#?ORGp5Ckdo#dlv46tY|>Sgfvn+mJ{hPjY9o*(w%f=i%IQ&Ub$A zJ#&7_<qJHQF3zTx9<0`F3T%;Ww(Q3e4%0(umY$jknt~*qu9PBv_4@XoOjD`V=xJVJq zYu5tINaIxicyfF3_uH$F?|<2TaQE^3FaEgwL;I`G{#?D&UTwBl@3fb0w!eSSe(=rV z$>~cwiIb%hr4u`eG6@`hLoYK*0>?^-MC3*u6&XUg!`v`(gSs%TQ^qBel*W|?^F%7P zk6Co=s-=k>_Pk)e5-_*w*16c%6&*$itw%2Qc+bM8Q7s^QVvS-#8NXqaap;z>pR|zB zLq=!Z4|Y)5>AxI+1^_AmY{`GQ3DF)lyZaOGDFA>Vq7e0K^$J+p6dM8nR*(;-3d5(I z<0@q)00>QXWGPH;L6MxCo~}$yUcPu2wq&rKi)9uBQMnX^wC;F#zC0hWIUZt%mO1t) zKknl?u2P@YqvL#mN7OH0!_FM7lQIs&vX9+*DO?bmjKYopz&HT#7Xe@qwsjajv|%1u zH;=T;gRR2IrlDo^4rmFT$^Cf4DAy?VqS`+$LADxSf?Vj$xJ?&b0ZW|{I*)MF;6e%J z*h{kEn8;9UOm>&;hO0O=>ddjD08oY-wan<6HX3K2vJMDi2J;AqE|Uqx z$gWiK_a?mL1dKEJE+UP?u;c{w8Mn&h{uUXjU_m3Ij5A#p5nu68Z}ux)j3p(Ml0VBDc#=O?Tl_Hi)yC`NZWwoIuu)4v1RUG8gK1C*gd7t$pA99K}X6nmP@Cb=Wd<* zc3};Qzre{BsNDcdZ*7>?x@mo9#lZPK@WzHYwr-9sUy6Y0p_6e-2eap literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9f581d45ab6f12657460c09ba00339aa11607724 GIT binary patch literal 10673 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 mUQKiEx^oAmIrq+`v- bool: + """验证密码""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """获取密码哈希""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建JWT访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> Optional[dict]: + """解码JWT令牌""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + return None diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..e0c8dea --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,38 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # 应用配置 + APP_NAME: str = "销售业绩管理系统" + DEBUG: bool = True + + # 数据库配置 + DATABASE_URL: str = "sqlite:///./sales_management.db" + + # JWT配置 + SECRET_KEY: str = "your-secret-key-here-change-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24小时 + + # 默认配置 + DEFAULT_SALARY_BASE_MIN: float = 4000.0 + DEFAULT_SALARY_BASE_MAX: float = 6000.0 + DEFAULT_SALARY_PERFORMANCE: float = 1000.0 + DEFAULT_SALARY_PERFORMANCE_THRESHOLD: float = 50.0 + + DEFAULT_COMMISSION_AI_MODEL: float = 0.03 + DEFAULT_COMMISSION_CLOUD_BASE: float = 0.10 + DEFAULT_COMMISSION_MAIN_PRODUCT: float = 0.08 + + DEFAULT_AGENT_EMPLOYEE_COMMISSION: float = 0.01 + DEFAULT_AGENT_PROFIT_SHARE_MIN: float = 0.60 + DEFAULT_AGENT_PROFIT_SHARE_MAX: float = 0.65 + + class Config: + env_file = ".env" + + +@lru_cache() +def get_settings(): + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..1b1d6e4 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,33 @@ +from sqlalchemy import create_engine, event +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, Session +from app.config import get_settings + +settings = get_settings() + +# 创建引擎 +engine = create_engine( + settings.DATABASE_URL, + connect_args={"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}, + echo=settings.DEBUG +) + +# 会话工厂 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 模型基类 +Base = declarative_base() + + +def get_db() -> Session: + """获取数据库会话""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """初始化数据库""" + Base.metadata.create_all(bind=engine) diff --git a/backend/app/init_data.py b/backend/app/init_data.py new file mode 100644 index 0000000..594a4d5 --- /dev/null +++ b/backend/app/init_data.py @@ -0,0 +1,80 @@ +from sqlalchemy.orm import Session +from app.models import User, Setting, ProductCategory +from app.config import get_settings +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +settings = get_settings() + + +def init_default_data(db: Session): + """初始化默认数据""" + + # 1. 创建默认管理员账号 + admin = db.query(User).filter(User.username == "admin").first() + if not admin: + admin = User( + username="admin", + password_hash=pwd_context.hash("admin123"), + role="admin", + name="系统管理员", + status=1 + ) + db.add(admin) + db.commit() + print("✅ 默认管理员账号已创建") + print(" 用户名: admin") + print(" 密码: admin123") + + # 2. 初始化默认配置 + default_settings = [ + ("salary_base_min", str(settings.DEFAULT_SALARY_BASE_MIN), "底薪范围最小值(元)", "salary"), + ("salary_base_max", str(settings.DEFAULT_SALARY_BASE_MAX), "底薪范围最大值(元)", "salary"), + ("salary_performance", str(settings.DEFAULT_SALARY_PERFORMANCE), "绩效奖金基数(元)", "salary"), + ("salary_performance_threshold", str(settings.DEFAULT_SALARY_PERFORMANCE_THRESHOLD), "绩效完成率阈值(%)", "salary"), + ("commission_ai_model", str(settings.DEFAULT_COMMISSION_AI_MODEL), "大模型类产品提成比例", "commission"), + ("commission_cloud_base", str(settings.DEFAULT_COMMISSION_CLOUD_BASE), "云基础类产品提成比例", "commission"), + ("commission_main_product", str(settings.DEFAULT_COMMISSION_MAIN_PRODUCT), "主推产品提成比例", "commission"), + ("agent_employee_commission", str(settings.DEFAULT_AGENT_EMPLOYEE_COMMISSION), "员工二级代理提成比例", "agent"), + ("agent_profit_share_min", str(settings.DEFAULT_AGENT_PROFIT_SHARE_MIN), "二级代理分佣比例最小值", "agent"), + ("agent_profit_share_max", str(settings.DEFAULT_AGENT_PROFIT_SHARE_MAX), "二级代理分佣比例最大值", "agent"), + ("company_name", "火山引擎渠道合作伙伴", "公司名称", "company"), + ] + + for key, value, desc, group in default_settings: + existing = db.query(Setting).filter(Setting.setting_key == key).first() + if not existing: + setting = Setting( + setting_key=key, + setting_value=value, + description=desc, + group_name=group + ) + db.add(setting) + + db.commit() + print("✅ 默认系统配置已初始化") + + # 3. 初始化默认产品分类 + default_categories = [ + ("大模型类产品", "ai_model", 0.03, 0.30, 0), + ("云基础类产品", "cloud_base", 0.10, 0.25, 0), + ("主推产品-直播大师", "main_live_master", 0.08, 0.35, 1), + ("主推产品-创作Agent", "main_create_agent", 0.08, 0.35, 1), + ] + + for name, code, commission, rebate, is_main in default_categories: + existing = db.query(ProductCategory).filter(ProductCategory.code == code).first() + if not existing: + category = ProductCategory( + name=name, + code=code, + commission_rate=commission, + monthly_rebate=rebate, + is_main_product=is_main, + status=1 + ) + db.add(category) + + db.commit() + print("✅ 默认产品分类已初始化") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e52bc6b --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,48 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.config import get_settings +from app.routers import auth, settings as settings_router, employees, agents, categories, performance, calculate, reports, dashboard + +settings = get_settings() + +app = FastAPI( + title=settings.APP_NAME, + description="销售业绩与收益计算管理系统 API", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc" +) + +# CORS配置 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 生产环境应限制具体域名 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 注册路由 +app.include_router(auth.router, prefix="/api/v1") +app.include_router(settings_router.router, prefix="/api/v1") +app.include_router(employees.router, prefix="/api/v1") +app.include_router(agents.router, prefix="/api/v1") +app.include_router(categories.router, prefix="/api/v1") +app.include_router(performance.router, prefix="/api/v1") +app.include_router(calculate.router, prefix="/api/v1") +app.include_router(reports.router, prefix="/api/v1") +app.include_router(dashboard.router, prefix="/api/v1") + + +@app.get("/") +def root(): + return { + "message": settings.APP_NAME, + "version": "1.0.0", + "docs": "/docs" + } + + +@app.get("/health") +def health_check(): + return {"status": "ok"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..5524b02 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,198 @@ +from sqlalchemy import Column, Integer, String, DateTime, Date, DECIMAL, Text, ForeignKey, CheckConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + role = Column(String(20), nullable=False, default="employee") # admin/employee/agent + name = Column(String(50), nullable=False) + phone = Column(String(20)) + email = Column(String(100)) + status = Column(Integer, nullable=False, default=1) # 0-禁用,1-启用 + last_login_at = Column(DateTime) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + employee = relationship("Employee", back_populates="user", uselist=False) + agent = relationship("SecondaryAgent", back_populates="user", uselist=False) + + __table_args__ = ( + CheckConstraint(role.in_(['admin', 'employee', 'agent'])), + ) + + +class Employee(Base): + __tablename__ = "employees" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + base_salary = Column(DECIMAL(10, 2), nullable=False, default=4000.00) + monthly_target = Column(DECIMAL(15, 2), nullable=False, default=0.00) + quarterly_target = Column(DECIMAL(15, 2), nullable=False, default=0.00) + half_year_target = Column(DECIMAL(15, 2), nullable=False, default=0.00) + yearly_target = Column(DECIMAL(15, 2), nullable=False, default=0.00) + hire_date = Column(Date) + department = Column(String(50)) + position = Column(String(50)) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + user = relationship("User", back_populates="employee") + agents = relationship("SecondaryAgent", back_populates="employee") + performance_records = relationship("PerformanceRecord", back_populates="employee") + calculation_results = relationship("CalculationResult", back_populates="employee") + + +class SecondaryAgent(Base): + __tablename__ = "secondary_agents" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False) + company_name = Column(String(100), nullable=False) + contact_name = Column(String(50)) + contact_phone = Column(String(20)) + profit_share_rate = Column(DECIMAL(5, 2), nullable=False, default=0.60) + address = Column(String(255)) + remark = Column(Text) + status = Column(Integer, nullable=False, default=1) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + user = relationship("User", back_populates="agent") + employee = relationship("Employee", back_populates="agents") + performance_records = relationship("PerformanceRecord", back_populates="agent") + + +class Setting(Base): + __tablename__ = "settings" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + setting_key = Column(String(50), unique=True, nullable=False) + setting_value = Column(Text, nullable=False) + description = Column(String(255)) + group_name = Column(String(50), default="general") + sort_order = Column(Integer, default=0) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class ProductCategory(Base): + __tablename__ = "product_categories" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String(50), nullable=False) + code = Column(String(30), unique=True) + parent_id = Column(Integer, ForeignKey("product_categories.id", ondelete="SET NULL")) + commission_rate = Column(DECIMAL(5, 2), nullable=False, default=0.03) + monthly_rebate = Column(DECIMAL(5, 2), nullable=False, default=0.10) + quarterly_rebate = Column(DECIMAL(5, 2)) + is_main_product = Column(Integer, nullable=False, default=0) + sort_order = Column(Integer, default=0) + status = Column(Integer, nullable=False, default=1) # 0-下线/禁用,1-启用 + # 火山引擎导入相关 + source_file = Column(String(100)) + import_batch = Column(String(50)) + last_import_at = Column(DateTime) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + parent = relationship("ProductCategory", remote_side=[id], backref="children") + performance_records = relationship("PerformanceRecord", back_populates="category") + + +class PerformanceRecord(Base): + __tablename__ = "performance_records" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + record_type = Column(String(20), nullable=False) # employee/agent + employee_id = Column(Integer, ForeignKey("employees.id", ondelete="SET NULL")) + agent_id = Column(Integer, ForeignKey("secondary_agents.id", ondelete="SET NULL")) + category_id = Column(Integer, ForeignKey("product_categories.id", ondelete="RESTRICT"), nullable=False) + amount = Column(DECIMAL(15, 2), nullable=False, default=0.00) + record_date = Column(Date, nullable=False) + customer_name = Column(String(100)) + order_no = Column(String(50)) + remark = Column(Text) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # 关联关系 + employee = relationship("Employee", back_populates="performance_records") + agent = relationship("SecondaryAgent", back_populates="performance_records") + category = relationship("ProductCategory", back_populates="performance_records") + + __table_args__ = ( + CheckConstraint(record_type.in_(['employee', 'agent'])), + ) + + +class CalculationResult(Base): + __tablename__ = "calculation_results" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False) + calc_period = Column(String(20), nullable=False) # monthly/quarterly/half_yearly/yearly + calc_year = Column(Integer, nullable=False) + calc_month = Column(Integer) + calc_quarter = Column(Integer) + period_start_date = Column(Date, nullable=False) + period_end_date = Column(Date, nullable=False) + + # 业绩数据 + total_performance = Column(DECIMAL(15, 2), nullable=False, default=0.00) + target_amount = Column(DECIMAL(15, 2), nullable=False, default=0.00) + completion_rate = Column(DECIMAL(5, 2), nullable=False, default=0.00) + + # 员工收益 + base_salary = Column(DECIMAL(10, 2), nullable=False, default=0.00) + performance_bonus = Column(DECIMAL(10, 2), nullable=False, default=0.00) + personal_commission = Column(DECIMAL(15, 2), nullable=False, default=0.00) + agent_commission = Column(DECIMAL(15, 2), nullable=False, default=0.00) + total_income = Column(DECIMAL(15, 2), nullable=False, default=0.00) + + # 公司收益 + company_rebate = Column(DECIMAL(15, 2), nullable=False, default=0.00) + company_cost = Column(DECIMAL(15, 2), nullable=False, default=0.00) + company_profit = Column(DECIMAL(15, 2), nullable=False, default=0.00) + + # 二级代理收益 + agent_performance = Column(DECIMAL(15, 2), nullable=False, default=0.00) + agent_share_amount = Column(DECIMAL(15, 2), nullable=False, default=0.00) + + # 计算详情(JSON格式) + detail_json = Column(Text) + + created_at = Column(DateTime, server_default=func.now()) + + # 关联关系 + employee = relationship("Employee", back_populates="calculation_results") + + __table_args__ = ( + CheckConstraint(calc_period.in_(['monthly', 'quarterly', 'half_yearly', 'yearly'])), + ) + + +class OperationLog(Base): + __tablename__ = "operation_logs" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + action = Column(String(50), nullable=False) + target_type = Column(String(50)) + target_id = Column(Integer) + old_value = Column(Text) + new_value = Column(Text) + ip_address = Column(String(50)) + user_agent = Column(String(255)) + created_at = Column(DateTime, server_default=func.now()) diff --git a/backend/app/routers/__pycache__/agents.cpython-312.pyc b/backend/app/routers/__pycache__/agents.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed909a255b0b40a25f5d14e1541f14bb6c5543a9 GIT binary patch literal 15778 zcmdU0dvM#vnO~3~0g@m^iF#6^UbYl5ku1MbD~=sEvE`>EtFhhWm^dsgKr$IV6aZ>0 z8=8?Dr;&O|El<;0KD!rZuDvP~wQ=umE^gXh+?&p{GxrY>>V_~Tow(D;iT|Tx_j=R6 zZoUOUQq0;;o9jK+)^f4f=eOVP_uJnt{>|-nB81StEL@5HvKOJ>!Gr0%`M|9sR)o$X z9`RHhC8!XUu!JlWJX_+{1RbIiwva7h57`q;h)J*^7QeH`9SLX1iNkc!tYydf_RGx5qqRj3Mw*|;xJ9jZ>$glZDCq1r@Us1C9ry1e$m9t}$wnd(&dHH`HiByAba_g?Nv^y=l$IHsRQcQ;7EpO@-J1j;%a}_^Oh$ z%{bO~3h~t?u`Le7Z{%wQCtoMD^7TR+-ypQ{exa3b6eyvs0U^XUnW0e+A2?+TwF`~0 zU8X>bzkIW>da=c9u>}bX-^#ZxwylV1=Ue$zi>uAH@-^_!f&`mT^(JMMg`XPCr%{IQ z5L)?8p_RYSYzN;ZP(mwiTd)vCpCG zp}9~4%dan~;eMep_C&E{GxR6d0J1mQzoDe`?#Y_qgEyeiphai*kIT_iG7{J6fv6-0 zZMx&p;XRM1CS*a>nH|EokmM!Zz5C$7;r?SI0#2;kzBD0-le%4!Bl3h4q;%$hAW6|w zGHB5qk3}S5FvSaT-L^9-#Q7klvwTDrGq&tL>Xd)61Qo4Oiki-0uAg$AfBtg{K z{=|4ZH7N+X=YTMhO7anL^3gFNDeK(+aY2kgLkCi0L5uE%oFfyWC?w_Z1mvwOjfwDy zX!1DI8-dKyL={5l!*^c(=$#*Y_|w1pXzuGD{pj_N=Dr@Z==O0@7>yp&X*n_`jhJGF z-~3EQx4wyBJrYoe;!((Q2nkk!=BX_(|12S!U>B^Q?*h$R^Knc`91U?6B(S_~(QfKi z$RRidnrHIqT_x$+d>mI2=YTlD4QmgCCM`ji?!Zz3LF?Q|Dlr~OPKJ|_gfIk;NjWki z<44_XK8+tuC55pK*!^xBi~=u=MkeC2UO6tNMx%0AIvNp$uo#gAor&IF+_dcHz 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 literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/auth.cpython-312.pyc b/backend/app/routers/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eaea4787bee7b5f720569645f5adc62627da4510 GIT binary patch literal 6118 zcma)AYj9M@mF|1zKIX00NE(eEmUvi&wq8jC88yP%ABEg}7~6tqd^ zRjUJ%96VIdBW%~a6J@u#!h8dddXQE=inI#sxos^+=J8<`>FjiL`_ent%80 z`N@$WPo)&WgOOLMV5md->Bs&7OAbZguy*#Pqa+qysrZ7gqKE4c+(wK- zoQ6XDQG{(+(75j-WKuj(!?vJ6-3MU9f=;MCtz(i<1ENdQh(4^;zHjZifqgs3f&FsPtK!UlN)2AK#gK&`i` zI*=74e;ws3tEYwPe0kNmgJ$LQj=o^70rs`U{`+qi8-!&pu|jMZV`6J!NMhp>C_xiL zGV95Oo}KmV1qC^%9o)P(F_@%$L?eC>PGBA-kYo(^Q!#ZSnZQ)UaWxX7o|vi|3Ug{j zHG+=S8ro!O$kbH_bhkjY6{=OLdK$){0`2KLOjX%k=Xy-vaC*-5=2=xyo$Vgm4k|2B zb&c7+i`(xAjiw%98!DRDnb(d}iC`m565&D&VV4dr?KyCPugk$%-{+Y&^>F8DwGb86hKN`Hbx>ml2|_(n^ccCL* z)MW+}1va9Rh7Isi`2`kk~ z1Z!|uHKr@L82IFrIvh)?8kGTGl!!)J!SUe`&1NA0A+#JKPB={nP8bTXlIIaZ;NkiW zP%HZ1ESeQIe$^1X>kG{Iw&Z+U@?}rvE7o5>HdWrfEDMd&lu)7dE?yp z{e0nScK)4_BBwcfiznX$tM39A6G%JICC92PIM#OM2L})D?d|I7?!U#6Ua%u5qS>@I zOFk?uvCk7o&bol@8Oz-F<*FIkpOgLLJ)gru>y?5AnF3faS{YqM zmc0@UAp|(9Z{ZNiB0x^>WAU%?M&o%%jxow?S|4|mM$>dWowyHne5f%A@>e@DKmEnnSw(?8|gwk!(((t^Xe=tg$;`5%w` z_@ermT>B_{YYEH$$Bo41d|l%;{}uml1KF-SLQP(f&i9V=j_zNUP{YQFV>4}sa&3q1 z)E}M_{CBIKyj$%b_g$;KQv1u7Z|<1vyw#cWJd>|^YFV`Tr0ju3H>&XEt6J_iY|01L z-3_dp3GB`Vc7I%%3+%gJ6SzF|)=<8o`C8wVzP!KTn&XP2&@4H`YVVT6S>argy>4j{ zc}0ok+^1y-Ey=X;SXgJ_102Upupwb)*Gx~g(GT1ue_>uD$!{2`u&%btBl0ng85G-L%21Sd>$s?m{Bq|xS*7G8RqU`rwX*E!5 zhc^;fdpru)XH~JnL4srUkTsFnM0z|`XKB$_f4ng_GRaIpWj)P)m%x*lUB^eEhOBz# zfqjDZ@4!hN5j_c6h-#3>ObJPF923Y0-vCnrQ8Z=4{mQ1x9dC8y{TpWdZ8?8i-q$?i z+nDohED_Z$Gu5rR>ehUb=oy&lQ9tWZ$6uald^Xqk>`ddHT;rZQjr*pCPA-+%!Nc9) z9qxj6^aq1fGKN!;#1H|Wrt+Yw8)_;-z{UhJo)Qew^1@`hj>BmZF@^~|9tq?y)LTNw z_9TfDurRf!hBXzQGz793YS&Z)!*47TDBbX0frxNi)iHs$A^B^l7$7rvEQeK2U}FiL zZa!1kKscEgj0_QoNmMd_#}E}do+LbhwkbLwH8%guuwNGJG0p%N9vdtLr9vvH8wBnw zgG+X;$R6l|F;+e2zp;Pc{MEFZoI(ge3a20D5L&cx9Cr`Ze1V>uM$dhL4%|cQ?xCi8 zsA(2G@dY}NgOU1sX#JOt%JJ5l{+rG_j_q0Lb64%pc4zHhmT#D9-+QNgU)Hm1=ef#@ zoy&;lDwmxCS9Y;?83|n3vRCGs#)g-X%r!05px~DGJky(Y|7mFIhcADcTtYQm`StaS z(0ts(nyKT1523rX4Q=b3>g=7~`oa>DIseTKi_m;xJcMSk2J!au^&|CT$F6JH`f2Xz z&$sS;zwS@1Q-_az+OdQ-bJA3G`y#X-%YTOEp$+ky7x~ScwD@?U!A%uF~`(46nMd-hv;H283a5W?#bO2$5xfDus zQ7+9#c@D~aN=S=QF)c-cVwN>oX!QI)j`DOcJZbu(E^dD7mfH|>l1SXoN>(>2i= zCd;YXbRZgFvXZJx2cyAsC>mmAHC3N(h&H4fqmAjNXj8g5+MI5Qws43?M8q!a?&U^3 zM|gXl(J+rTAnbhsVIT1x5sI~~tk(Ym!Zj7Ovsi8I3kU})YG;#h@-FA|%THWK+D3!U zI6NW}sX4;g?W`1ZO51rHBJH@IwBiQh#*HMfUByi=NJomwqH{?&xuLAtk#KY62)G6Q zctjK&#^KS{ijfsO3y1OS(K*id-EcMv4iS#DHzR~_8{tR?liEvlb4fd%N7_qs_2Z6; zxz4}NT<2HL^@ii~ctM4B8858RzTi4*ebcvS4^^ynJ(k}k}Bb+7o5+jDUML0`*SSK{XTeVOV@1vAtj98yes1>NJ(QrDEiAYv+Tua6KQkp?x zB%MoT`w599GVyGhSWV@ocs8BWGX1d}&2CQ^RztZ(>jnMA`bIWFQDGvRiS0<}Mwa$l z4Ko`sF|eH5Y+87rk$6+i@gM!iQw zd!(p~xQSZS;+xjbk83LWc`EwVLce}B60n-q6&H7{v)m7oU42A1y1s_*w!e6C|H%1+ zuOH?t4<>q?CfHFpELuuBn=y8z`YqS4K8+fLrur?<4lT7k)=xAj$j&oIXd;WhhSiz; zm-o+o^syyE)8UijzF-wbWQFqoqkKX1QarbQbre zK=#IByZW@0-9lkfbS`8tfK?NVX_-vc(7FX!3{%??Thby;-t8Z-sX(>SAEJG*V;I7%^{~P@O@#(Wv^)I@XfL9bUwm0Rq zgsMG_-HwOoe%g^@2|N(tEI%cBE}PK_SUE{qo{Dx$#G0X5JlT?ipC z?W;HOfaj)=ECPJx19Ll>D>D8u? z1T317cHV?gNmtE`Mud<<;07d9qBW*eO^1otyu(JodEcmWq-4mE%1j&mWS z(P=Yz6PaAH$tjngCYJ;on~_W|305&9nOqXQz>I|DQRPT+EeB+*VwWjn_d#JRnum-w zr*j9Q;Q^)mCD>!YSz*akoRzAaKlTnN&nUY&15BNz4n${9)8WJZYFd7Qe#uwUv>QDo zJdSpAk(xeON5^>%Pk#88O&~0Z(HjZ@z+ui(9p~G(2%O(9pTG3lzH`TZdhvtzE+7BN zyzx-wY^zOy?-@2nM60}5`O%cnj<#Juj&~&Til*VF;WS1FP6X-pTL&1 zJwbJY!p2gt1*;C2W^Cy#OJeJ{JhY^@!E=!n?DcVdZup9nbx_dT5lb1y*1ysc1jY0{sIzw z{(`$s+QOX)EXvme@=f#d!NvnC_OHkX>JK#UZ_d{>99Xn}Q9jgkVAK9h`KH#X8nwk0pZg?!`g+y zrwhFxFKO=a3Maf$Pe9nCb{C1Z?MWjXjlgLFXt{)0Ai^prtZR9jEitF{63Yd7Oi%0~ z@k%6x-C~H`#4$vcZ-B_8gvbhqOsOg&m$0EKA}du9S+0hr-x-lrQ>8vrt)^oDWjt8K zQZK^1DVQRb4~kd;tScj%DFN1+tbJ5HQj9nTT=1UhvU^8deSpOmfA-w!?Nc-&|LA@l(BeA718rayzRdV=|uxv$^++Rc0KEy$?3H6QNC&+Qo5b*$sF$H$u< zy`qRAe*uX;|CAT`=jT1Xd}vO-ratcrz<*tM%BT3{!S#g@3WV~()_iTl6-D&Q1tdx` zqm6r-9dqe^+5sR4Rug!@@=vdb7cYyL+XBAAEaDMn5k~Q%2f-;pj=vrdOhNY^SS5aLQO)&|6V@5Lhq;q9g(K5}B zf)x!#aT>F!GX<;dbGc&}qm7k~5i z`R89uyviZe2SJ%_YZu=+I{EQGoICc@$zKmo9vq?T;pEP+IdIa<{Z{&YW{Gq=bZ55a zrJ&SylHb*;z?ss=ZA%4gtS}=m9@{3tmUMs)+l-2Cufl|4Hm9IzxG8|nZ!=4p@*{u8 zp*17^3E#58_5bM!FlRBg^xl*HO=Ie&g8XMNw3^1@+YWV(hi^H$@xy!Hz4!C*UDI9T z{0i6Ty{<8--Nnlx%jd9rM!u$*PRW_HFw=3N>Bf^R-qcne6%$o66cZ(#tDlO_CQ8Bx zGZN-T`4VgkSiqtwV5vxGO922Qw4zZmCM+8znqM}yQN=Ov=Qykmh|h?-IiqA$jL=r< zaA9{fEsW4s(NrR|9>!3Mj^xGB$ybiqO!WM_FP|U$=<@NQb058U;hi@w?0ac)|M2DG zd)GT&q6!P8_rNIxmqYJ`3=TTo0vVXeW_mw}0NxQ71sYu@24yBoqtL*((j&FwPYL&UHh=HEYVJFv3;$ z|3|nWl6h{`Y7TwExz@OaPuvZw+k{WrydW=bULy&Akfb$kVNal&U3+#vZQp)^<4_%O zT3A~;_aA(FSo^yYi4xPofW$)Es==!hWL0+$)##2XRC5c{96%{FEgMnE0m!CIb0%Mv zxJfshjZ|%16T!%)lq?0oK@yv&kh(0kWe5Xh9Opaz+}JOPHi$1*@itT}4c)43NVEuJU(aw*mKJESWB6#ixbF zo&nc0uHBpgS7$6SOsdr3#lC7no5}DuL!cwee~vqeJ|OVRCcrx-j4eGTS+{d z!5ZynH&-+T-~eFbpwup?s0LM4P^uE3>St129TZ)c@3OeO6!0=1O@ZK49hxCr8Qk2I zO|nW*OWB~{&@G7>dj;-QBg!>p@Uy%3A%xxq=)0Y(7TKOvm;K)(TTd__YB;cQ|Hk1b zCqj!)g_ewmmgH+^pQ>#iuWdi{*m!LxD=iwYT{N=eH#-mS9Iw6oswC9=|0tuH&|dQ6 z-hzUBbyrjrYCh92?@Zf*Q*F18x80r(N2Vm9v95rGKwZIYgY-fG>H1kW!s)*NNQ0Gp zTDz*J2A!yJt!olaG&S^e2`9R|Am7ovE-3tKP+Hd{L?V`K^yd{tv4xK#^{#ordXv_4}81yVtQF<7&qO(*n+i8#@_%BQ4QWnacHkIW7&P-#X_ zE!kFR|2)C2z2RQg5_4JIutc1Q8+0jjhAR|H=p_ba=Nf`E#xI%krEm;q+@XJ?-f90j zvx+{85Q4x)e}YG-AaERa7ImCOJ^z7vzCd%&qWP0(&KD?r7Tx_vUgr3(k<9T|YLPoQ zym%xt*17VeYt^7UrHWjf8}6S%@SO6qQes-^9=lZVBVK(u{A_s4x8SHT7@pwnyrQCD z%kYA~?>^n!HrD<(6V2bd;+n^8;qrk{K>*=P_uPU6LP18Z+JeGF6$Kl=V*RcPh+kRY zS?wkc)VDm_GW7jJ#$d|?xA@EU`T5qie9LV3-9{T;{i^?TearCo4z-U6C+ioVu4|q? za!m&(=L5~d^Y+gw2pydKr8l&1>)!hcQU@m&WaJ6%Te7#apg>7Q9)H0Fk{i_q3m%ZX zFhL(kewZIEGuhiADg>mqGjn7r4d%r-ChttVCq09$SBnG6>9{AT?L-i uE-h_N+tCoHysg^iECiMQKtTZEOZS|D1VV9hm}qYfh@gGh6&^$ui~ldRg~{&# literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/categories.cpython-312.pyc b/backend/app/routers/__pycache__/categories.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4b6a05f773033de33e88baccea0fdb6ce9951eb7 GIT binary patch literal 24511 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 Y+MuP7<3SFlfLia9cXa_qJtF_V08i_&mjD0& literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/performance.cpython-312.pyc b/backend/app/routers/__pycache__/performance.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e2657f29d609993fbbcf5509d152d1e887c3bee9 GIT binary patch literal 19087 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 LGSoBb>Bav86pff8 literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/reports.cpython-312.pyc b/backend/app/routers/__pycache__/reports.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0b0618a4220493283cd217ed65224cc1ea47ac3b GIT binary patch literal 6252 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 BeXali literal 0 HcmV?d00001 diff --git a/backend/app/routers/agents.py b/backend/app/routers/agents.py new file mode 100644 index 0000000..efcd1e7 --- /dev/null +++ b/backend/app/routers/agents.py @@ -0,0 +1,367 @@ +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from datetime import datetime +from decimal import Decimal + +from app.database import get_db +from app.models import User, Employee, SecondaryAgent, OperationLog +from app.routers.auth import get_current_user, get_current_admin + +router = APIRouter(prefix="/agents", tags=["二级代理管理"]) + + +# Pydantic模型 +class AgentBase(BaseModel): + company_name: str + contact_name: Optional[str] = None + contact_phone: Optional[str] = None + profit_share_rate: Decimal = Field(default=0.60) + address: Optional[str] = None + remark: Optional[str] = None + employee_id: int + + +class AgentCreate(AgentBase): + username: Optional[str] = None + password: Optional[str] = None + + +class AgentUpdate(BaseModel): + company_name: Optional[str] = None + contact_name: Optional[str] = None + contact_phone: Optional[str] = None + profit_share_rate: Optional[Decimal] = None + address: Optional[str] = None + remark: Optional[str] = None + employee_id: Optional[int] = None + status: Optional[int] = None + + +class AgentResponse(BaseModel): + id: int + user_id: Optional[int] + employee_id: int + employee_name: str + company_name: str + contact_name: Optional[str] + contact_phone: Optional[str] + profit_share_rate: Decimal + address: Optional[str] + remark: Optional[str] + status: int + created_at: str + updated_at: str + + class Config: + from_attributes = True + + +def log_operation(db: Session, user_id: int, action: str, target_type: str, target_id: int, + old_value: Optional[str] = None, new_value: Optional[str] = None): + """记录操作日志""" + log = OperationLog( + user_id=user_id, + action=action, + target_type=target_type, + target_id=target_id, + old_value=old_value, + new_value=new_value + ) + db.add(log) + db.commit() + + +@router.get("", response_model=dict) +def get_agents( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=1000), + search: Optional[str] = None, + employee_id: Optional[int] = None, + status: Optional[int] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取代理列表""" + query = db.query(SecondaryAgent).join(Employee).join(User, Employee.user_id == User.id) + + # 搜索条件 + if search: + query = query.filter( + (SecondaryAgent.company_name.contains(search)) | + (SecondaryAgent.contact_name.contains(search)) | + (SecondaryAgent.contact_phone.contains(search)) + ) + + # 员工筛选 + if employee_id: + query = query.filter(SecondaryAgent.employee_id == employee_id) + + # 状态筛选 + if status is not None: + query = query.filter(SecondaryAgent.status == status) + + # 非管理员只能查看自己关联的代理 + if current_user.role != "admin": + # 检查当前用户是否是员工 + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee: + query = query.filter(SecondaryAgent.employee_id == employee.id) + + # 计算总数 + total = query.count() + + # 分页 + agents = query.offset((page - 1) * page_size).limit(page_size).all() + + # 构建响应数据 + items = [] + for agent in agents: + items.append({ + "id": agent.id, + "user_id": agent.user_id, + "employee_id": agent.employee_id, + "employee_name": agent.employee.user.name if agent.employee else None, + "company_name": agent.company_name, + "contact_name": agent.contact_name, + "contact_phone": agent.contact_phone, + "profit_share_rate": str(agent.profit_share_rate), + "address": agent.address, + "remark": agent.remark, + "status": agent.status, + "created_at": agent.created_at.isoformat() if agent.created_at else None, + "updated_at": agent.updated_at.isoformat() if agent.updated_at else None + }) + + return { + "code": 200, + "message": "success", + "data": { + "items": items, + "total": total, + "page": page, + "page_size": page_size + } + } + + +@router.get("/{agent_id}", response_model=dict) +def get_agent( + agent_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取代理详情""" + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == agent_id).first() + + if not agent: + raise HTTPException(status_code=404, detail="代理不存在") + + # 权限检查:非管理员只能查看自己的代理 + if current_user.role != "admin": + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if not employee or agent.employee_id != employee.id: + raise HTTPException(status_code=403, detail="权限不足") + + return { + "code": 200, + "message": "success", + "data": { + "id": agent.id, + "user_id": agent.user_id, + "employee_id": agent.employee_id, + "employee_name": agent.employee.user.name if agent.employee else None, + "company_name": agent.company_name, + "contact_name": agent.contact_name, + "contact_phone": agent.contact_phone, + "profit_share_rate": str(agent.profit_share_rate), + "address": agent.address, + "remark": agent.remark, + "status": agent.status, + "created_at": agent.created_at.isoformat() if agent.created_at else None, + "updated_at": agent.updated_at.isoformat() if agent.updated_at else None + } + } + + +@router.post("", response_model=dict) +def create_agent( + data: AgentCreate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """创建代理""" + # 检查员工是否存在 + employee = db.query(Employee).filter(Employee.id == data.employee_id).first() + if not employee: + raise HTTPException(status_code=400, detail="所属员工不存在") + + user_id = None + + # 如果提供了用户名和密码,创建用户账号 + if data.username and data.password: + # 检查用户名是否已存在 + existing_user = db.query(User).filter(User.username == data.username).first() + if existing_user: + raise HTTPException(status_code=400, detail="用户名已存在") + + # 创建用户 + from app.auth import get_password_hash + user = User( + username=data.username, + password_hash=get_password_hash(data.password), + role="agent", + name=data.contact_name or data.company_name, + phone=data.contact_phone, + status=1 + ) + db.add(user) + db.flush() # 获取user.id + user_id = user.id + + # 创建代理 + agent = SecondaryAgent( + user_id=user_id, + employee_id=data.employee_id, + company_name=data.company_name, + contact_name=data.contact_name, + contact_phone=data.contact_phone, + profit_share_rate=data.profit_share_rate, + address=data.address, + remark=data.remark, + status=1 + ) + db.add(agent) + db.commit() + db.refresh(agent) + + # 记录操作日志 + log_operation(db, current_admin.id, "CREATE_AGENT", "agent", agent.id, + new_value=f"创建代理: {data.company_name}, 所属员工: {employee.user.name}") + + return { + "code": 200, + "message": "代理创建成功", + "data": { + "id": agent.id, + "user_id": agent.user_id, + "employee_id": agent.employee_id, + "employee_name": employee.user.name, + "company_name": agent.company_name, + "contact_name": agent.contact_name, + "contact_phone": agent.contact_phone, + "profit_share_rate": str(agent.profit_share_rate), + "address": agent.address, + "remark": agent.remark, + "status": agent.status, + "created_at": agent.created_at.isoformat() if agent.created_at else None + } + } + + +@router.put("/{agent_id}", response_model=dict) +def update_agent( + agent_id: int, + data: AgentUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """更新代理""" + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == agent_id).first() + + if not agent: + raise HTTPException(status_code=404, detail="代理不存在") + + # 如果更换所属员工,检查新员工是否存在 + if data.employee_id is not None and data.employee_id != agent.employee_id: + employee = db.query(Employee).filter(Employee.id == data.employee_id).first() + if not employee: + raise HTTPException(status_code=400, detail="所属员工不存在") + + # 记录旧值 + old_value = f"company={agent.company_name}, contact={agent.contact_name}, profit_rate={agent.profit_share_rate}, employee_id={agent.employee_id}" + + # 更新代理信息 + if data.company_name is not None: + agent.company_name = data.company_name + if data.contact_name is not None: + agent.contact_name = data.contact_name + # 同时更新用户名称 + if agent.user: + agent.user.name = data.contact_name + if data.contact_phone is not None: + agent.contact_phone = data.contact_phone + # 同时更新用户电话 + if agent.user: + agent.user.phone = data.contact_phone + if data.profit_share_rate is not None: + agent.profit_share_rate = data.profit_share_rate + if data.address is not None: + agent.address = data.address + if data.remark is not None: + agent.remark = data.remark + if data.employee_id is not None: + agent.employee_id = data.employee_id + if data.status is not None: + agent.status = data.status + # 同时更新用户状态 + if agent.user: + agent.user.status = data.status + + db.commit() + db.refresh(agent) + + # 记录操作日志 + new_value = f"company={agent.company_name}, contact={agent.contact_name}, profit_rate={agent.profit_share_rate}, employee_id={agent.employee_id}" + log_operation(db, current_admin.id, "UPDATE_AGENT", "agent", agent_id, + old_value=old_value, new_value=new_value) + + return { + "code": 200, + "message": "代理信息更新成功", + "data": { + "id": agent.id, + "user_id": agent.user_id, + "employee_id": agent.employee_id, + "employee_name": agent.employee.user.name if agent.employee else None, + "company_name": agent.company_name, + "contact_name": agent.contact_name, + "contact_phone": agent.contact_phone, + "profit_share_rate": str(agent.profit_share_rate), + "address": agent.address, + "remark": agent.remark, + "status": agent.status, + "updated_at": agent.updated_at.isoformat() if agent.updated_at else None + } + } + + +@router.delete("/{agent_id}", response_model=dict) +def delete_agent( + agent_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """删除代理""" + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == agent_id).first() + + if not agent: + raise HTTPException(status_code=404, detail="代理不存在") + + company_name = agent.company_name + user_id = agent.user_id + + # 删除代理 + db.delete(agent) + db.commit() + + # 记录操作日志 + log_operation(db, current_admin.id, "DELETE_AGENT", "agent", agent_id, + old_value=f"删除代理: {company_name}, user_id={user_id}") + + return { + "code": 200, + "message": "代理删除成功", + "data": None + } diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..03b1341 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,152 @@ +from typing import Any +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from pydantic import BaseModel +from datetime import timedelta + +from app.database import get_db +from app.auth import verify_password, create_access_token, get_password_hash +from app.models import User +from app.config import get_settings + +router = APIRouter(prefix="/auth", tags=["认证"]) +settings = get_settings() + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +# Pydantic模型 +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str + expires_in: int + user: dict + + +class UserInfo(BaseModel): + id: int + username: str + name: str + role: str + phone: str = None + email: str = None + last_login_at: str = None + + +# 依赖函数 +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User: + """获取当前登录用户""" + from app.auth import decode_token + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的认证凭据", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = decode_token(token) + if payload is None: + raise credentials_exception + + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + + user = db.query(User).filter(User.id == int(user_id), User.status == 1).first() + if user is None: + raise credentials_exception + + return user + + +async def get_current_admin(current_user: User = Depends(get_current_user)) -> User: + """验证当前用户是管理员""" + if current_user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足,需要管理员权限" + ) + return current_user + + +# 路由 +@router.post("/login", response_model=dict) +def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + """用户登录""" + # 查找用户 + user = db.query(User).filter(User.username == form_data.username).first() + + if not user or not verify_password(form_data.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if user.status != 1: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="账号已被禁用" + ) + + # 更新最后登录时间 + from sqlalchemy import func + user.last_login_at = func.now() + db.commit() + + # 创建访问令牌 + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": str(user.id), "role": user.role}, + expires_delta=access_token_expires + ) + + return { + "code": 200, + "message": "登录成功", + "data": { + "access_token": access_token, + "token_type": "bearer", + "expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + "user": { + "id": user.id, + "username": user.username, + "name": user.name, + "role": user.role + } + } + } + + +@router.post("/logout") +def logout(current_user: User = Depends(get_current_user)): + """用户退出(前端清除token即可)""" + return { + "code": 200, + "message": "退出成功", + "data": None + } + + +@router.get("/me", response_model=dict) +def get_me(current_user: User = Depends(get_current_user)): + """获取当前用户信息""" + return { + "code": 200, + "message": "success", + "data": { + "id": current_user.id, + "username": current_user.username, + "name": current_user.name, + "role": current_user.role, + "phone": current_user.phone, + "email": current_user.email, + "last_login_at": current_user.last_login_at.isoformat() if current_user.last_login_at else None + } + } diff --git a/backend/app/routers/calculate.py b/backend/app/routers/calculate.py new file mode 100644 index 0000000..e20a722 --- /dev/null +++ b/backend/app/routers/calculate.py @@ -0,0 +1,217 @@ +from typing import Optional, Literal +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field + +from app.database import get_db +from app.models import User +from app.routers.auth import get_current_user, get_current_admin +from app.services.calculate_service import ( + calculate_employee_income, + calculate_company_profit, + calculate_agent_profit, + get_calculation_history, + get_calculation_detail +) + +router = APIRouter(prefix="/calculate", tags=["收益计算"]) + + +# Pydantic模型 +class EmployeeCalculateRequest(BaseModel): + period: Literal["monthly", "quarterly", "half_yearly", "yearly"] = Field(..., description="计算周期") + year: int = Field(..., description="年份") + month: Optional[int] = Field(None, ge=1, le=12, description="月份(月度周期需要)") + quarter: Optional[int] = Field(None, ge=1, le=4, description="季度(季度周期需要)") + + +class CompanyCalculateRequest(BaseModel): + period: Literal["monthly", "quarterly", "half_yearly", "yearly"] = Field(..., description="计算周期") + year: int = Field(..., description="年份") + month: Optional[int] = Field(None, ge=1, le=12, description="月份(月度周期需要)") + quarter: Optional[int] = Field(None, ge=1, le=4, description="季度(季度周期需要)") + + +class AgentCalculateRequest(BaseModel): + period: Literal["monthly", "quarterly", "half_yearly", "yearly"] = Field(..., description="计算周期") + year: int = Field(..., description="年份") + month: Optional[int] = Field(None, ge=1, le=12, description="月份(月度周期需要)") + quarter: Optional[int] = Field(None, ge=1, le=4, description="季度(季度周期需要)") + + +@router.post("/employee/{employee_id}", response_model=dict) +def calculate_employee( + employee_id: int, + data: EmployeeCalculateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """计算员工收益""" + # 权限检查:非管理员只能计算自己的收益 + if current_user.role != "admin": + from app.models import Employee + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if not employee or employee.id != employee_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足,只能查看自己的收益" + ) + + try: + result = calculate_employee_income( + db=db, + employee_id=employee_id, + period=data.period, + year=data.year, + month=data.month, + quarter=data.quarter, + save_result=True + ) + return { + "code": 200, + "message": "计算成功", + "data": result + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"计算失败: {str(e)}") + + +@router.get("/history", response_model=dict) +def get_history( + employee_id: Optional[int] = None, + period: Optional[str] = None, + year: Optional[int] = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取计算历史列表""" + # 权限检查:非管理员只能查看自己的历史 + if current_user.role != "admin": + from app.models import Employee + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee: + employee_id = employee.id + else: + return { + "code": 200, + "message": "success", + "data": {"items": [], "total": 0, "page": page, "page_size": page_size} + } + + result = get_calculation_history( + db=db, + employee_id=employee_id, + period=period, + year=year, + page=page, + page_size=page_size + ) + + return { + "code": 200, + "message": "success", + "data": result + } + + +@router.get("/history/{calculation_id}", response_model=dict) +def get_history_detail( + calculation_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取计算历史详情""" + result = get_calculation_detail(db, calculation_id) + + if not result: + raise HTTPException(status_code=404, detail="计算记录不存在") + + # 权限检查:非管理员只能查看自己的记录 + if current_user.role != "admin": + from app.models import Employee + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if not employee or result["employee_id"] != employee.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足" + ) + + return { + "code": 200, + "message": "success", + "data": result + } + + +@router.post("/company", response_model=dict) +def calculate_company( + data: CompanyCalculateRequest, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """计算公司收益汇总(仅管理员)""" + try: + result = calculate_company_profit( + db=db, + period=data.period, + year=data.year, + month=data.month, + quarter=data.quarter, + save_result=False + ) + return { + "code": 200, + "message": "计算成功", + "data": result + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"计算失败: {str(e)}") + + +@router.post("/agent/{agent_id}", response_model=dict) +def calculate_agent( + agent_id: int, + data: AgentCalculateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """计算代理收益""" + # 权限检查:非管理员只能查看自己关联的代理 + if current_user.role != "admin": + from app.models import Employee, SecondaryAgent + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee: + agent = db.query(SecondaryAgent).filter( + SecondaryAgent.id == agent_id, + SecondaryAgent.employee_id == employee.id + ).first() + if not agent: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足" + ) + + try: + result = calculate_agent_profit( + db=db, + agent_id=agent_id, + period=data.period, + year=data.year, + month=data.month, + quarter=data.quarter + ) + return { + "code": 200, + "message": "计算成功", + "data": result + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"计算失败: {str(e)}") diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py new file mode 100644 index 0000000..8e115e0 --- /dev/null +++ b/backend/app/routers/categories.py @@ -0,0 +1,559 @@ +from typing import Optional, List, Dict, Any +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from datetime import datetime +from decimal import Decimal +import json + +from app.database import get_db +from app.models import User, ProductCategory, OperationLog +from app.routers.auth import get_current_user, get_current_admin + +router = APIRouter(prefix="/categories", tags=["产品分类管理"]) + + +# Pydantic模型 +class CategoryBase(BaseModel): + name: str + code: Optional[str] = None + parent_id: Optional[int] = None + commission_rate: Decimal = Field(default=0.03) + monthly_rebate: Decimal = Field(default=0.10) + quarterly_rebate: Optional[Decimal] = None + is_main_product: int = Field(default=0) + sort_order: int = Field(default=0) + + +class CategoryCreate(CategoryBase): + pass + + +class CategoryUpdate(BaseModel): + name: Optional[str] = None + code: Optional[str] = None + parent_id: Optional[int] = None + commission_rate: Optional[Decimal] = None + monthly_rebate: Optional[Decimal] = None + quarterly_rebate: Optional[Decimal] = None + is_main_product: Optional[int] = None + sort_order: Optional[int] = None + status: Optional[int] = None + + +class CategoryImportItem(BaseModel): + name: str + code: str + parent_code: Optional[str] = None + commission_rate: Decimal = Field(default=0.03) + monthly_rebate: Decimal = Field(default=0.10) + quarterly_rebate: Optional[Decimal] = None + is_main_product: int = Field(default=0) + + +class CategoryImportData(BaseModel): + items: List[CategoryImportItem] + source_file: str + + +# 导入预览缓存(实际生产环境应使用Redis等) +_import_preview_cache: Dict[str, Any] = {} + + +def log_operation(db: Session, user_id: int, action: str, target_type: str, target_id: Optional[int] = None, + old_value: Optional[str] = None, new_value: Optional[str] = None): + """记录操作日志""" + log = OperationLog( + user_id=user_id, + action=action, + target_type=target_type, + target_id=target_id, + old_value=old_value, + new_value=new_value + ) + db.add(log) + db.commit() + + +def build_category_tree(categories: List[ProductCategory], parent_id: Optional[int] = None) -> List[Dict]: + """构建分类树形结构""" + tree = [] + for cat in categories: + if cat.parent_id == parent_id: + children = build_category_tree(categories, cat.id) + node = { + "id": cat.id, + "name": cat.name, + "code": cat.code, + "parent_id": cat.parent_id, + "commission_rate": str(cat.commission_rate), + "monthly_rebate": str(cat.monthly_rebate), + "quarterly_rebate": str(cat.quarterly_rebate) if cat.quarterly_rebate else None, + "is_main_product": cat.is_main_product, + "sort_order": cat.sort_order, + "status": cat.status, + "source_file": cat.source_file, + "import_batch": cat.import_batch, + "last_import_at": cat.last_import_at.isoformat() if cat.last_import_at else None, + "created_at": cat.created_at.isoformat() if cat.created_at else None, + "updated_at": cat.updated_at.isoformat() if cat.updated_at else None, + "children": children if children else [] + } + tree.append(node) + return sorted(tree, key=lambda x: x["sort_order"]) + + +@router.get("", response_model=dict) +def get_categories( + tree: bool = Query(True, description="是否返回树形结构"), + status: Optional[int] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取分类列表(树形结构)""" + query = db.query(ProductCategory) + + # 状态筛选 + if status is not None: + query = query.filter(ProductCategory.status == status) + + categories = query.order_by(ProductCategory.sort_order, ProductCategory.id).all() + + if tree: + data = build_category_tree(categories) + else: + data = [] + for cat in categories: + data.append({ + "id": cat.id, + "name": cat.name, + "code": cat.code, + "parent_id": cat.parent_id, + "commission_rate": str(cat.commission_rate), + "monthly_rebate": str(cat.monthly_rebate), + "quarterly_rebate": str(cat.quarterly_rebate) if cat.quarterly_rebate else None, + "is_main_product": cat.is_main_product, + "sort_order": cat.sort_order, + "status": cat.status, + "source_file": cat.source_file, + "import_batch": cat.import_batch, + "last_import_at": cat.last_import_at.isoformat() if cat.last_import_at else None, + "created_at": cat.created_at.isoformat() if cat.created_at else None, + "updated_at": cat.updated_at.isoformat() if cat.updated_at else None + }) + + return { + "code": 200, + "message": "success", + "data": data + } + + +@router.get("/{category_id}", response_model=dict) +def get_category( + category_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取分类详情""" + category = db.query(ProductCategory).filter(ProductCategory.id == category_id).first() + + if not category: + raise HTTPException(status_code=404, detail="分类不存在") + + # 获取父级信息 + parent_name = None + if category.parent_id: + parent = db.query(ProductCategory).filter(ProductCategory.id == category.parent_id).first() + if parent: + parent_name = parent.name + + # 获取子分类 + children = db.query(ProductCategory).filter(ProductCategory.parent_id == category_id).all() + children_data = [{"id": c.id, "name": c.name, "code": c.code} for c in children] + + return { + "code": 200, + "message": "success", + "data": { + "id": category.id, + "name": category.name, + "code": category.code, + "parent_id": category.parent_id, + "parent_name": parent_name, + "commission_rate": str(category.commission_rate), + "monthly_rebate": str(category.monthly_rebate), + "quarterly_rebate": str(category.quarterly_rebate) if category.quarterly_rebate else None, + "is_main_product": category.is_main_product, + "sort_order": category.sort_order, + "status": category.status, + "source_file": category.source_file, + "import_batch": category.import_batch, + "last_import_at": category.last_import_at.isoformat() if category.last_import_at else None, + "children": children_data, + "created_at": category.created_at.isoformat() if category.created_at else None, + "updated_at": category.updated_at.isoformat() if category.updated_at else None + } + } + + +@router.post("", response_model=dict) +def create_category( + data: CategoryCreate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """创建分类""" + # 检查code是否已存在 + if data.code: + existing = db.query(ProductCategory).filter(ProductCategory.code == data.code).first() + if existing: + raise HTTPException(status_code=400, detail="分类编码已存在") + + # 检查父级是否存在 + if data.parent_id: + parent = db.query(ProductCategory).filter(ProductCategory.id == data.parent_id).first() + if not parent: + raise HTTPException(status_code=400, detail="父级分类不存在") + + category = ProductCategory( + name=data.name, + code=data.code, + parent_id=data.parent_id, + commission_rate=data.commission_rate, + monthly_rebate=data.monthly_rebate, + quarterly_rebate=data.quarterly_rebate, + is_main_product=data.is_main_product, + sort_order=data.sort_order, + status=1 + ) + db.add(category) + db.commit() + db.refresh(category) + + # 记录操作日志 + log_operation(db, current_admin.id, "CREATE_CATEGORY", "category", category.id, + new_value=f"创建分类: {data.name}, code={data.code}") + + return { + "code": 200, + "message": "分类创建成功", + "data": { + "id": category.id, + "name": category.name, + "code": category.code, + "parent_id": category.parent_id, + "commission_rate": str(category.commission_rate), + "monthly_rebate": str(category.monthly_rebate), + "quarterly_rebate": str(category.quarterly_rebate) if category.quarterly_rebate else None, + "is_main_product": category.is_main_product, + "sort_order": category.sort_order, + "status": category.status, + "created_at": category.created_at.isoformat() if category.created_at else None + } + } + + +@router.put("/{category_id}", response_model=dict) +def update_category( + category_id: int, + data: CategoryUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """更新分类""" + category = db.query(ProductCategory).filter(ProductCategory.id == category_id).first() + + if not category: + raise HTTPException(status_code=404, detail="分类不存在") + + # 如果更换父级,检查是否存在且不会形成循环 + if data.parent_id is not None and data.parent_id != category.parent_id: + if data.parent_id == category_id: + raise HTTPException(status_code=400, detail="不能将自己设为父级") + parent = db.query(ProductCategory).filter(ProductCategory.id == data.parent_id).first() + if not parent: + raise HTTPException(status_code=400, detail="父级分类不存在") + # 检查是否会成为子分类的子级(循环引用) + def check_circular(parent_id: int, target_id: int) -> bool: + if parent_id == target_id: + return True + children = db.query(ProductCategory).filter(ProductCategory.parent_id == parent_id).all() + for child in children: + if check_circular(child.id, target_id): + return True + return False + + if check_circular(category_id, data.parent_id): + raise HTTPException(status_code=400, detail="不能将分类设为其子分类的子级") + + # 检查code是否已存在 + if data.code and data.code != category.code: + existing = db.query(ProductCategory).filter(ProductCategory.code == data.code).first() + if existing: + raise HTTPException(status_code=400, detail="分类编码已存在") + + # 记录旧值 + old_value = f"name={category.name}, code={category.code}, commission_rate={category.commission_rate}" + + # 更新分类信息 + if data.name is not None: + category.name = data.name + if data.code is not None: + category.code = data.code + if data.parent_id is not None: + category.parent_id = data.parent_id + if data.commission_rate is not None: + category.commission_rate = data.commission_rate + if data.monthly_rebate is not None: + category.monthly_rebate = data.monthly_rebate + if data.quarterly_rebate is not None: + category.quarterly_rebate = data.quarterly_rebate + if data.is_main_product is not None: + category.is_main_product = data.is_main_product + if data.sort_order is not None: + category.sort_order = data.sort_order + if data.status is not None: + category.status = data.status + + db.commit() + db.refresh(category) + + # 记录操作日志 + new_value = f"name={category.name}, code={category.code}, commission_rate={category.commission_rate}" + log_operation(db, current_admin.id, "UPDATE_CATEGORY", "category", category_id, + old_value=old_value, new_value=new_value) + + return { + "code": 200, + "message": "分类更新成功", + "data": { + "id": category.id, + "name": category.name, + "code": category.code, + "parent_id": category.parent_id, + "commission_rate": str(category.commission_rate), + "monthly_rebate": str(category.monthly_rebate), + "quarterly_rebate": str(category.quarterly_rebate) if category.quarterly_rebate else None, + "is_main_product": category.is_main_product, + "sort_order": category.sort_order, + "status": category.status, + "updated_at": category.updated_at.isoformat() if category.updated_at else None + } + } + + +@router.delete("/{category_id}", response_model=dict) +def delete_category( + category_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """删除分类""" + category = db.query(ProductCategory).filter(ProductCategory.id == category_id).first() + + if not category: + raise HTTPException(status_code=404, detail="分类不存在") + + # 检查是否有子分类 + children = db.query(ProductCategory).filter(ProductCategory.parent_id == category_id).first() + if children: + raise HTTPException(status_code=400, detail="该分类下有子分类,请先删除子分类") + + # 检查是否有关联的业绩记录 + from app.models import PerformanceRecord + records = db.query(PerformanceRecord).filter(PerformanceRecord.category_id == category_id).first() + if records: + raise HTTPException(status_code=400, detail="该分类已关联业绩记录,无法删除") + + name = category.name + + # 删除分类 + db.delete(category) + db.commit() + + # 记录操作日志 + log_operation(db, current_admin.id, "DELETE_CATEGORY", "category", category_id, + old_value=f"删除分类: {name}") + + return { + "code": 200, + "message": "分类删除成功", + "data": None + } + + +@router.post("/import", response_model=dict) +def preview_import_categories( + data: CategoryImportData, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """火山引擎清单导入(预览)""" + import uuid + + batch_id = str(uuid.uuid4())[:8] + preview_items = [] + errors = [] + + # 收集现有分类编码 + existing_codes = {c.code: c for c in db.query(ProductCategory).all() if c.code} + + # 构建编码到ID的映射(用于父级关联) + code_to_id = {} + new_code_to_index = {} + + for idx, item in enumerate(data.items): + if not item.code: + errors.append({"index": idx, "message": "分类编码不能为空"}) + continue + + if not item.name: + errors.append({"index": idx, "message": "分类名称不能为空"}) + continue + + # 检查编码是否重复(在当前导入数据中) + if item.code in new_code_to_index: + errors.append({"index": idx, "message": f"编码 '{item.code}' 在导入数据中重复"}) + continue + + new_code_to_index[item.code] = idx + + # 判断是新增还是更新 + action = "update" if item.code in existing_codes else "create" + existing = existing_codes.get(item.code) + + preview_item = { + "index": idx, + "code": item.code, + "name": item.name, + "parent_code": item.parent_code, + "commission_rate": str(item.commission_rate), + "monthly_rebate": str(item.monthly_rebate), + "quarterly_rebate": str(item.quarterly_rebate) if item.quarterly_rebate else None, + "is_main_product": item.is_main_product, + "action": action, + "existing_id": existing.id if existing else None, + "existing_name": existing.name if existing else None + } + preview_items.append(preview_item) + + # 检查父级编码是否存在 + for item in preview_items: + if item["parent_code"]: + if item["parent_code"] not in existing_codes and item["parent_code"] not in new_code_to_index: + errors.append({"index": item["index"], "message": f"父级编码 '{item['parent_code']}' 不存在"}) + + # 保存预览数据到缓存 + preview_data = { + "batch_id": batch_id, + "source_file": data.source_file, + "items": data.items, + "preview": preview_items, + "errors": errors, + "created_at": datetime.now().isoformat() + } + _import_preview_cache[batch_id] = preview_data + + return { + "code": 200, + "message": "预览生成成功", + "data": { + "batch_id": batch_id, + "total": len(data.items), + "create_count": len([p for p in preview_items if p["action"] == "create"]), + "update_count": len([p for p in preview_items if p["action"] == "update"]), + "error_count": len(errors), + "preview": preview_items, + "errors": errors + } + } + + +@router.post("/import/confirm", response_model=dict) +def confirm_import_categories( + batch_id: str, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """确认导入""" + if batch_id not in _import_preview_cache: + raise HTTPException(status_code=404, detail="导入批次不存在或已过期") + + preview_data = _import_preview_cache[batch_id] + + if preview_data["errors"]: + raise HTTPException(status_code=400, detail="导入数据存在错误,无法确认导入") + + items = preview_data["items"] + source_file = preview_data["source_file"] + + # 收集现有分类 + existing_codes = {c.code: c for c in db.query(ProductCategory).all() if c.code} + + # 第一步:创建/更新所有分类(不处理parent_id) + created_items = {} + updated_count = 0 + created_count = 0 + + for item in items: + if item.code in existing_codes: + # 更新现有分类 + category = existing_codes[item.code] + category.name = item.name + category.commission_rate = item.commission_rate + category.monthly_rebate = item.monthly_rebate + category.quarterly_rebate = item.quarterly_rebate + category.is_main_product = item.is_main_product + category.source_file = source_file + category.import_batch = batch_id + category.last_import_at = datetime.now() + updated_count += 1 + else: + # 创建新分类 + category = ProductCategory( + name=item.name, + code=item.code, + commission_rate=item.commission_rate, + monthly_rebate=item.monthly_rebate, + quarterly_rebate=item.quarterly_rebate, + is_main_product=item.is_main_product, + source_file=source_file, + import_batch=batch_id, + last_import_at=datetime.now(), + status=1 + ) + db.add(category) + db.flush() # 获取ID + created_count += 1 + + created_items[item.code] = category + + # 第二步:处理父级关联 + for item in items: + if item.parent_code: + category = created_items[item.code] + if item.parent_code in created_items: + category.parent_id = created_items[item.parent_code].id + elif item.parent_code in existing_codes: + category.parent_id = existing_codes[item.parent_code].id + + db.commit() + + # 记录操作日志 + log_operation(db, current_admin.id, "IMPORT_CATEGORIES", "category", None, + new_value=f"导入分类: 创建{created_count}个, 更新{updated_count}个, 来源:{source_file}") + + # 清理缓存 + del _import_preview_cache[batch_id] + + return { + "code": 200, + "message": "导入成功", + "data": { + "batch_id": batch_id, + "created": created_count, + "updated": updated_count, + "total": len(items) + } + } diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..24ddda5 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -0,0 +1,172 @@ +from typing import Optional, Literal +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func, and_, extract +from datetime import datetime, timedelta +from decimal import Decimal + +from app.database import get_db +from app.models import ( + User, Employee, SecondaryAgent, ProductCategory, + PerformanceRecord, CalculationResult +) +from app.routers.auth import get_current_user + +router = APIRouter(prefix="/dashboard", tags=["数据看板"]) + + +@router.get("/summary", response_model=dict) +def get_dashboard_summary( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取仪表盘汇总数据""" + # 本月业绩总额 + now = datetime.now() + start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + monthly_performance = db.query( + func.coalesce(func.sum(PerformanceRecord.amount), Decimal("0")) + ).filter( + PerformanceRecord.record_date >= start_of_month.date() + ).scalar() + + # 员工收益总额(本月) + monthly_income = db.query( + func.coalesce(func.sum(CalculationResult.total_income), Decimal("0")) + ).filter( + CalculationResult.calc_year == now.year, + CalculationResult.calc_month == now.month + ).scalar() + + # 员工总数 + employee_count = db.query(Employee).join(User).filter(User.status == 1).count() + + # 二级代理总数 + agent_count = db.query(SecondaryAgent).join(User).filter(User.status == 1).count() + + # 产品分类数 + category_count = db.query(ProductCategory).filter(ProductCategory.status == 1).count() + + return { + "code": 200, + "message": "success", + "data": { + "total_performance": str(monthly_performance), + "total_income": str(monthly_income), + "employee_count": employee_count, + "agent_count": agent_count, + "category_count": category_count + } + } + + +@router.get("/chart", response_model=dict) +def get_dashboard_chart( + type: Literal["performance", "category"] = Query(..., description="图表类型"), + months: int = Query(6, ge=1, le=12, description="显示月数"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取仪表盘图表数据""" + + if type == "performance": + # 业绩趋势数据 + end_date = datetime.now() + data = [] + + for i in range(months - 1, -1, -1): + month_date = end_date - timedelta(days=i * 30) + start_of_month = month_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + if month_date.month == 12: + end_of_month = month_date.replace(year=month_date.year + 1, month=1, day=1) + else: + end_of_month = month_date.replace(month=month_date.month + 1, day=1) + + amount = db.query( + func.coalesce(func.sum(PerformanceRecord.amount), Decimal("0")) + ).filter( + PerformanceRecord.record_date >= start_of_month.date(), + PerformanceRecord.record_date < end_of_month.date() + ).scalar() + + data.append({ + "month": month_date.strftime("%Y-%m"), + "amount": float(amount) + }) + + return { + "code": 200, + "message": "success", + "data": data + } + + elif type == "category": + # 产品分类占比数据 + results = db.query( + ProductCategory.name, + func.coalesce(func.sum(PerformanceRecord.amount), Decimal("0")).label("amount") + ).outerjoin( + PerformanceRecord, ProductCategory.id == PerformanceRecord.category_id + ).filter( + ProductCategory.status == 1 + ).group_by(ProductCategory.id).all() + + data = [ + {"name": name, "amount": float(amount)} + for name, amount in results if amount > 0 + ] + + return { + "code": 200, + "message": "success", + "data": data + } + + return { + "code": 400, + "message": "不支持的图表类型", + "data": [] + } + + +@router.get("/recent-performance", response_model=dict) +def get_recent_performance( + limit: int = Query(5, ge=1, le=20), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取最近业绩记录""" + records = db.query(PerformanceRecord).order_by( + PerformanceRecord.record_date.desc() + ).limit(limit).all() + + data = [] + for record in records: + employee_name = None + if record.employee_id: + emp = db.query(Employee).filter(Employee.id == record.employee_id).first() + if emp and emp.user: + employee_name = emp.user.name + + category_name = None + if record.category_id: + cat = db.query(ProductCategory).filter(ProductCategory.id == record.category_id).first() + if cat: + category_name = cat.name + + data.append({ + "id": record.id, + "record_date": record.record_date.isoformat() if record.record_date else None, + "employee_name": employee_name, + "category_name": category_name, + "amount": str(record.amount), + "customer_name": record.customer_name, + "order_no": record.order_no + }) + + return { + "code": 200, + "message": "success", + "data": data + } diff --git a/backend/app/routers/employees.py b/backend/app/routers/employees.py new file mode 100644 index 0000000..b5cbb82 --- /dev/null +++ b/backend/app/routers/employees.py @@ -0,0 +1,421 @@ +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from datetime import date +from decimal import Decimal + +from app.database import get_db +from app.models import User, Employee, OperationLog +from app.routers.auth import get_current_user, get_current_admin + +router = APIRouter(prefix="/employees", tags=["员工管理"]) + + +# Pydantic模型 +class EmployeeTarget(BaseModel): + monthly_target: Decimal = Field(default=0) + quarterly_target: Decimal = Field(default=0) + half_year_target: Decimal = Field(default=0) + yearly_target: Decimal = Field(default=0) + + +class EmployeeBase(BaseModel): + name: str + phone: Optional[str] = None + email: Optional[str] = None + department: Optional[str] = None + position: Optional[str] = None + base_salary: Decimal = Field(default=4000) + hire_date: Optional[date] = None + + +class EmployeeCreate(EmployeeBase): + username: str + password: str + targets: Optional[EmployeeTarget] = None + + +class EmployeeUpdate(BaseModel): + name: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + department: Optional[str] = None + position: Optional[str] = None + base_salary: Optional[Decimal] = None + hire_date: Optional[date] = None + status: Optional[int] = None + + +class EmployeeResponse(BaseModel): + id: int + user_id: int + username: str + name: str + phone: Optional[str] + email: Optional[str] + department: Optional[str] + position: Optional[str] + base_salary: Decimal + monthly_target: Decimal + quarterly_target: Decimal + half_year_target: Decimal + yearly_target: Decimal + hire_date: Optional[date] + status: int + created_at: str + updated_at: str + + class Config: + from_attributes = True + + +class EmployeeListResponse(BaseModel): + items: List[EmployeeResponse] + total: int + page: int + page_size: int + + +def log_operation(db: Session, user_id: int, action: str, target_type: str, target_id: int, + old_value: Optional[str] = None, new_value: Optional[str] = None): + """记录操作日志""" + log = OperationLog( + user_id=user_id, + action=action, + target_type=target_type, + target_id=target_id, + old_value=old_value, + new_value=new_value + ) + db.add(log) + db.commit() + + +@router.get("", response_model=dict) +def get_employees( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=1000), + search: Optional[str] = None, + department: Optional[str] = None, + status: Optional[int] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取员工列表(支持分页、搜索)""" + query = db.query(Employee).join(User) + + # 搜索条件 + if search: + query = query.filter( + (User.name.contains(search)) | + (User.username.contains(search)) | + (User.phone.contains(search)) + ) + + # 部门筛选 + if department: + query = query.filter(Employee.department == department) + + # 状态筛选 + if status is not None: + query = query.filter(User.status == status) + + # 计算总数 + total = query.count() + + # 分页 + employees = query.offset((page - 1) * page_size).limit(page_size).all() + + # 构建响应数据 + items = [] + for emp in employees: + items.append({ + "id": emp.id, + "user_id": emp.user_id, + "username": emp.user.username, + "name": emp.user.name, + "phone": emp.user.phone, + "email": emp.user.email, + "department": emp.department, + "position": emp.position, + "base_salary": str(emp.base_salary), + "monthly_target": str(emp.monthly_target), + "quarterly_target": str(emp.quarterly_target), + "half_year_target": str(emp.half_year_target), + "yearly_target": str(emp.yearly_target), + "hire_date": emp.hire_date.isoformat() if emp.hire_date else None, + "status": emp.user.status, + "created_at": emp.created_at.isoformat() if emp.created_at else None, + "updated_at": emp.updated_at.isoformat() if emp.updated_at else None + }) + + return { + "code": 200, + "message": "success", + "data": { + "items": items, + "total": total, + "page": page, + "page_size": page_size + } + } + + +@router.get("/{employee_id}", response_model=dict) +def get_employee( + employee_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取员工详情""" + employee = db.query(Employee).filter(Employee.id == employee_id).first() + + if not employee: + raise HTTPException(status_code=404, detail="员工不存在") + + return { + "code": 200, + "message": "success", + "data": { + "id": employee.id, + "user_id": employee.user_id, + "username": employee.user.username, + "name": employee.user.name, + "phone": employee.user.phone, + "email": employee.user.email, + "department": employee.department, + "position": employee.position, + "base_salary": str(employee.base_salary), + "monthly_target": str(employee.monthly_target), + "quarterly_target": str(employee.quarterly_target), + "half_year_target": str(employee.half_year_target), + "yearly_target": str(employee.yearly_target), + "hire_date": employee.hire_date.isoformat() if employee.hire_date else None, + "status": employee.user.status, + "created_at": employee.created_at.isoformat() if employee.created_at else None, + "updated_at": employee.updated_at.isoformat() if employee.updated_at else None + } + } + + +@router.post("", response_model=dict) +def create_employee( + data: EmployeeCreate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """创建员工(同时创建用户账号)""" + # 检查用户名是否已存在 + existing_user = db.query(User).filter(User.username == data.username).first() + if existing_user: + raise HTTPException(status_code=400, detail="用户名已存在") + + # 检查手机号是否已存在 + if data.phone: + existing_phone = db.query(User).filter(User.phone == data.phone).first() + if existing_phone: + raise HTTPException(status_code=400, detail="手机号已存在") + + # 创建用户 + from app.auth import get_password_hash + user = User( + username=data.username, + password_hash=get_password_hash(data.password), + role="employee", + name=data.name, + phone=data.phone, + email=data.email, + status=1 + ) + db.add(user) + db.flush() # 获取user.id + + # 创建员工记录 + targets = data.targets or EmployeeTarget() + employee = Employee( + user_id=user.id, + base_salary=data.base_salary, + monthly_target=targets.monthly_target, + quarterly_target=targets.quarterly_target, + half_year_target=targets.half_year_target, + yearly_target=targets.yearly_target, + hire_date=data.hire_date, + department=data.department, + position=data.position + ) + db.add(employee) + db.commit() + db.refresh(employee) + + # 记录操作日志 + log_operation(db, current_admin.id, "CREATE_EMPLOYEE", "employee", employee.id, + new_value=f"创建员工: {data.name}, 用户名: {data.username}") + + return { + "code": 200, + "message": "员工创建成功", + "data": { + "id": employee.id, + "user_id": employee.user_id, + "username": user.username, + "name": user.name, + "phone": user.phone, + "email": user.email, + "department": employee.department, + "position": employee.position, + "base_salary": str(employee.base_salary), + "monthly_target": str(employee.monthly_target), + "quarterly_target": str(employee.quarterly_target), + "half_year_target": str(employee.half_year_target), + "yearly_target": str(employee.yearly_target), + "hire_date": employee.hire_date.isoformat() if employee.hire_date else None, + "status": user.status, + "created_at": employee.created_at.isoformat() if employee.created_at else None + } + } + + +@router.put("/{employee_id}", response_model=dict) +def update_employee( + employee_id: int, + data: EmployeeUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """更新员工信息""" + employee = db.query(Employee).filter(Employee.id == employee_id).first() + + if not employee: + raise HTTPException(status_code=404, detail="员工不存在") + + user = employee.user + + # 记录旧值 + old_value = f"name={user.name}, phone={user.phone}, department={employee.department}, position={employee.position}" + + # 更新用户信息 + if data.name is not None: + user.name = data.name + if data.phone is not None: + user.phone = data.phone + if data.email is not None: + user.email = data.email + if data.status is not None: + user.status = data.status + + # 更新员工信息 + if data.department is not None: + employee.department = data.department + if data.position is not None: + employee.position = data.position + if data.base_salary is not None: + employee.base_salary = data.base_salary + if data.hire_date is not None: + employee.hire_date = data.hire_date + + db.commit() + db.refresh(employee) + + # 记录操作日志 + new_value = f"name={user.name}, phone={user.phone}, department={employee.department}, position={employee.position}" + log_operation(db, current_admin.id, "UPDATE_EMPLOYEE", "employee", employee.id, + old_value=old_value, new_value=new_value) + + return { + "code": 200, + "message": "员工信息更新成功", + "data": { + "id": employee.id, + "user_id": employee.user_id, + "username": user.username, + "name": user.name, + "phone": user.phone, + "email": user.email, + "department": employee.department, + "position": employee.position, + "base_salary": str(employee.base_salary), + "monthly_target": str(employee.monthly_target), + "quarterly_target": str(employee.quarterly_target), + "half_year_target": str(employee.half_year_target), + "yearly_target": str(employee.yearly_target), + "hire_date": employee.hire_date.isoformat() if employee.hire_date else None, + "status": user.status, + "updated_at": employee.updated_at.isoformat() if employee.updated_at else None + } + } + + +@router.delete("/{employee_id}", response_model=dict) +def delete_employee( + employee_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """删除员工""" + employee = db.query(Employee).filter(Employee.id == employee_id).first() + + if not employee: + raise HTTPException(status_code=404, detail="员工不存在") + + user_id = employee.user_id + user_name = employee.user.name + + # 删除员工(级联删除用户) + db.delete(employee) + db.commit() + + # 记录操作日志 + log_operation(db, current_admin.id, "DELETE_EMPLOYEE", "employee", employee_id, + old_value=f"删除员工: {user_name}, user_id={user_id}") + + return { + "code": 200, + "message": "员工删除成功", + "data": None + } + + +@router.put("/{employee_id}/targets", response_model=dict) +def update_employee_targets( + employee_id: int, + data: EmployeeTarget, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """更新员工目标""" + employee = db.query(Employee).filter(Employee.id == employee_id).first() + + if not employee: + raise HTTPException(status_code=404, detail="员工不存在") + + # 记录旧值 + old_value = f"monthly={employee.monthly_target}, quarterly={employee.quarterly_target}, half_year={employee.half_year_target}, yearly={employee.yearly_target}" + + # 更新目标 + employee.monthly_target = data.monthly_target + employee.quarterly_target = data.quarterly_target + employee.half_year_target = data.half_year_target + employee.yearly_target = data.yearly_target + + db.commit() + db.refresh(employee) + + # 记录操作日志 + new_value = f"monthly={data.monthly_target}, quarterly={data.quarterly_target}, half_year={data.half_year_target}, yearly={data.yearly_target}" + log_operation(db, current_admin.id, "UPDATE_EMPLOYEE_TARGETS", "employee", employee_id, + old_value=old_value, new_value=new_value) + + return { + "code": 200, + "message": "员工目标更新成功", + "data": { + "id": employee.id, + "name": employee.user.name, + "monthly_target": str(employee.monthly_target), + "quarterly_target": str(employee.quarterly_target), + "half_year_target": str(employee.half_year_target), + "yearly_target": str(employee.yearly_target), + "updated_at": employee.updated_at.isoformat() if employee.updated_at else None + } + } diff --git a/backend/app/routers/performance.py b/backend/app/routers/performance.py new file mode 100644 index 0000000..f5948d3 --- /dev/null +++ b/backend/app/routers/performance.py @@ -0,0 +1,406 @@ +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from datetime import date, datetime +from decimal import Decimal + +from app.database import get_db +from app.models import User, Employee, SecondaryAgent, ProductCategory, PerformanceRecord, OperationLog +from app.routers.auth import get_current_user, get_current_admin + +router = APIRouter(prefix="/performance", tags=["业绩管理"]) + + +# Pydantic模型 +class PerformanceBase(BaseModel): + record_type: str = Field(default="employee", description="记录类型: employee/agent") + employee_id: Optional[int] = None + agent_id: Optional[int] = None + category_id: int + amount: Decimal = Field(gt=0) + record_date: date + customer_name: Optional[str] = None + order_no: Optional[str] = None + remark: Optional[str] = None + + +class PerformanceCreate(PerformanceBase): + pass + + +class PerformanceUpdate(BaseModel): + record_type: Optional[str] = None + employee_id: Optional[int] = None + agent_id: Optional[int] = None + category_id: Optional[int] = None + amount: Optional[Decimal] = None + record_date: Optional[date] = None + customer_name: Optional[str] = None + order_no: Optional[str] = None + remark: Optional[str] = None + + +class PerformanceResponse(BaseModel): + id: int + record_type: str + employee_id: Optional[int] + employee_name: Optional[str] + agent_id: Optional[int] + agent_name: Optional[str] + category_id: int + category_name: str + amount: str + record_date: str + customer_name: Optional[str] + order_no: Optional[str] + remark: Optional[str] + created_by: int + created_by_name: str + created_at: str + updated_at: str + + class Config: + from_attributes = True + + +class PerformanceListResponse(BaseModel): + list: List[PerformanceResponse] + total: int + page: int + page_size: int + + +def log_operation(db: Session, user_id: int, action: str, target_type: str, target_id: int, + old_value: Optional[str] = None, new_value: Optional[str] = None): + """记录操作日志""" + log = OperationLog( + user_id=user_id, + action=action, + target_type=target_type, + target_id=target_id, + old_value=old_value, + new_value=new_value + ) + db.add(log) + db.commit() + + +@router.get("", response_model=dict) +def get_performance_list( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + employee_id: Optional[int] = None, + agent_id: Optional[int] = None, + category_id: Optional[int] = None, + record_type: Optional[str] = None, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取业绩列表""" + query = db.query(PerformanceRecord) + + # 筛选条件 + if employee_id: + query = query.filter(PerformanceRecord.employee_id == employee_id) + if agent_id: + query = query.filter(PerformanceRecord.agent_id == agent_id) + if category_id: + query = query.filter(PerformanceRecord.category_id == category_id) + if record_type: + query = query.filter(PerformanceRecord.record_type == record_type) + if start_date: + query = query.filter(PerformanceRecord.record_date >= start_date) + if end_date: + query = query.filter(PerformanceRecord.record_date <= end_date) + + # 普通员工只能看自己的业绩 + if current_user.role == "employee": + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee: + query = query.filter(PerformanceRecord.employee_id == employee.id) + + # 计算总数 + total = query.count() + + # 分页 + records = query.order_by(PerformanceRecord.record_date.desc()).offset((page - 1) * page_size).limit(page_size).all() + + # 构建响应数据 + list_data = [] + for record in records: + employee_name = None + if record.employee_id: + emp = db.query(Employee).filter(Employee.id == record.employee_id).first() + if emp: + employee_name = emp.user.name if emp.user else None + + agent_name = None + if record.agent_id: + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == record.agent_id).first() + if agent: + agent_name = agent.company_name + + category_name = "" + if record.category_id: + cat = db.query(ProductCategory).filter(ProductCategory.id == record.category_id).first() + if cat: + category_name = cat.name + + created_by_name = "" + if record.created_by: + creator = db.query(User).filter(User.id == record.created_by).first() + if creator: + created_by_name = creator.name or creator.username + + list_data.append({ + "id": record.id, + "record_type": record.record_type, + "employee_id": record.employee_id, + "employee_name": employee_name, + "agent_id": record.agent_id, + "agent_name": agent_name, + "category_id": record.category_id, + "category_name": category_name, + "amount": str(record.amount), + "record_date": record.record_date.isoformat() if record.record_date else None, + "customer_name": record.customer_name, + "order_no": record.order_no, + "remark": record.remark, + "created_by": record.created_by, + "created_by_name": created_by_name, + "created_at": record.created_at.isoformat() if record.created_at else None, + "updated_at": record.updated_at.isoformat() if record.updated_at else None + }) + + return { + "code": 200, + "message": "success", + "data": { + "list": list_data, + "total": total, + "page": page, + "page_size": page_size + } + } + + +@router.get("/{record_id}", response_model=dict) +def get_performance_detail( + record_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取业绩详情""" + record = db.query(PerformanceRecord).filter(PerformanceRecord.id == record_id).first() + + if not record: + raise HTTPException(status_code=404, detail="业绩记录不存在") + + # 普通员工只能看自己的业绩 + if current_user.role == "employee": + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee and record.employee_id != employee.id: + raise HTTPException(status_code=403, detail="无权查看该业绩记录") + + employee_name = None + if record.employee_id: + emp = db.query(Employee).filter(Employee.id == record.employee_id).first() + if emp: + employee_name = emp.user.name if emp.user else None + + agent_name = None + if record.agent_id: + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == record.agent_id).first() + if agent: + agent_name = agent.company_name + + category_name = "" + if record.category_id: + cat = db.query(ProductCategory).filter(ProductCategory.id == record.category_id).first() + if cat: + category_name = cat.name + + created_by_name = "" + if record.created_by: + creator = db.query(User).filter(User.id == record.created_by).first() + if creator: + created_by_name = creator.name or creator.username + + return { + "code": 200, + "message": "success", + "data": { + "id": record.id, + "record_type": record.record_type, + "employee_id": record.employee_id, + "employee_name": employee_name, + "agent_id": record.agent_id, + "agent_name": agent_name, + "category_id": record.category_id, + "category_name": category_name, + "amount": str(record.amount), + "record_date": record.record_date.isoformat() if record.record_date else None, + "customer_name": record.customer_name, + "order_no": record.order_no, + "remark": record.remark, + "created_by": record.created_by, + "created_by_name": created_by_name, + "created_at": record.created_at.isoformat() if record.created_at else None, + "updated_at": record.updated_at.isoformat() if record.updated_at else None + } + } + + +@router.post("", response_model=dict) +def create_performance( + data: PerformanceCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """创建业绩记录""" + # 验证员工或代理是否存在 + if data.record_type == "employee" and data.employee_id: + employee = db.query(Employee).filter(Employee.id == data.employee_id).first() + if not employee: + raise HTTPException(status_code=400, detail="员工不存在") + elif data.record_type == "agent" and data.agent_id: + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == data.agent_id).first() + if not agent: + raise HTTPException(status_code=400, detail="代理不存在") + + # 验证产品分类是否存在 + category = db.query(ProductCategory).filter(ProductCategory.id == data.category_id).first() + if not category: + raise HTTPException(status_code=400, detail="产品分类不存在") + + # 创建记录 + record = PerformanceRecord( + record_type=data.record_type, + employee_id=data.employee_id, + agent_id=data.agent_id, + category_id=data.category_id, + amount=data.amount, + record_date=data.record_date, + customer_name=data.customer_name, + order_no=data.order_no, + remark=data.remark, + created_by=current_user.id + ) + db.add(record) + db.commit() + db.refresh(record) + + # 记录操作日志 + log_operation(db, current_user.id, "CREATE_PERFORMANCE", "performance", record.id, + new_value=f"创建业绩记录: 金额={data.amount}, 日期={data.record_date}") + + return { + "code": 200, + "message": "业绩记录创建成功", + "data": { + "id": record.id, + "record_type": record.record_type, + "employee_id": record.employee_id, + "agent_id": record.agent_id, + "category_id": record.category_id, + "amount": str(record.amount), + "record_date": record.record_date.isoformat() if record.record_date else None, + "created_at": record.created_at.isoformat() if record.created_at else None + } + } + + +@router.put("/{record_id}", response_model=dict) +def update_performance( + record_id: int, + data: PerformanceUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """更新业绩记录""" + record = db.query(PerformanceRecord).filter(PerformanceRecord.id == record_id).first() + + if not record: + raise HTTPException(status_code=404, detail="业绩记录不存在") + + # 记录旧值 + old_value = f"amount={record.amount}, date={record.record_date}, category_id={record.category_id}" + + # 更新字段 + if data.record_type is not None: + record.record_type = data.record_type + if data.employee_id is not None: + record.employee_id = data.employee_id + if data.agent_id is not None: + record.agent_id = data.agent_id + if data.category_id is not None: + # 验证产品分类是否存在 + category = db.query(ProductCategory).filter(ProductCategory.id == data.category_id).first() + if not category: + raise HTTPException(status_code=400, detail="产品分类不存在") + record.category_id = data.category_id + if data.amount is not None: + record.amount = data.amount + if data.record_date is not None: + record.record_date = data.record_date + if data.customer_name is not None: + record.customer_name = data.customer_name + if data.order_no is not None: + record.order_no = data.order_no + if data.remark is not None: + record.remark = data.remark + + db.commit() + db.refresh(record) + + # 记录操作日志 + new_value = f"amount={record.amount}, date={record.record_date}, category_id={record.category_id}" + log_operation(db, current_user.id, "UPDATE_PERFORMANCE", "performance", record_id, + old_value=old_value, new_value=new_value) + + return { + "code": 200, + "message": "业绩记录更新成功", + "data": { + "id": record.id, + "record_type": record.record_type, + "employee_id": record.employee_id, + "agent_id": record.agent_id, + "category_id": record.category_id, + "amount": str(record.amount), + "record_date": record.record_date.isoformat() if record.record_date else None, + "updated_at": record.updated_at.isoformat() if record.updated_at else None + } + } + + +@router.delete("/{record_id}", response_model=dict) +def delete_performance( + record_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """删除业绩记录""" + record = db.query(PerformanceRecord).filter(PerformanceRecord.id == record_id).first() + + if not record: + raise HTTPException(status_code=404, detail="业绩记录不存在") + + # 记录旧值 + old_value = f"删除业绩记录: 金额={record.amount}, 日期={record.record_date}" + + db.delete(record) + db.commit() + + # 记录操作日志 + log_operation(db, current_admin.id, "DELETE_PERFORMANCE", "performance", record_id, + old_value=old_value) + + return { + "code": 200, + "message": "业绩记录删除成功", + "data": None + } diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py new file mode 100644 index 0000000..6feeaf4 --- /dev/null +++ b/backend/app/routers/reports.py @@ -0,0 +1,150 @@ +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from fastapi.responses import StreamingResponse + +from app.database import get_db +from app.models import User +from app.routers.auth import get_current_user, get_current_admin +from app.services.report_service import ( + export_employee_report, + export_company_report, + export_performance_report +) + +router = APIRouter(prefix="/reports", tags=["报表导出"]) + + +@router.get("/employee/{employee_id}/excel") +def export_employee_excel( + employee_id: int, + period: str = Query(..., description="计算周期: monthly/quarterly/half_yearly/yearly"), + year: int = Query(..., description="年份"), + month: Optional[int] = Query(None, description="月份(月度周期需要)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """导出员工收益Excel报表""" + # 权限检查:非管理员只能导出自己的报表 + if current_user.role != "admin": + from app.models import Employee + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if not employee or employee.id != employee_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足,只能导出自己的报表" + ) + + try: + excel_bytes, filename = export_employee_report( + db=db, + employee_id=employee_id, + period=period, + year=year, + month=month + ) + + return StreamingResponse( + iter([excel_bytes]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") + + +@router.get("/company/excel") +def export_company_excel( + period: str = Query(..., description="计算周期: monthly/quarterly/half_yearly/yearly"), + year: int = Query(..., description="年份"), + month: Optional[int] = Query(None, description="月份(月度周期需要)"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin) +): + """导出公司收益Excel报表(仅管理员)""" + try: + excel_bytes, filename = export_company_report( + db=db, + period=period, + year=year, + month=month + ) + + return StreamingResponse( + iter([excel_bytes]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") + + +@router.get("/performance/excel") +def export_performance_excel( + start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"), + end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD)"), + employee_id: Optional[int] = Query(None, description="员工ID"), + agent_id: Optional[int] = Query(None, description="代理ID"), + category_id: Optional[int] = Query(None, description="分类ID"), + record_type: Optional[str] = Query(None, description="记录类型: employee/agent"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """导出业绩报表Excel""" + from datetime import datetime + + # 构建筛选条件 + filters = {} + + if start_date: + try: + filters['start_date'] = datetime.strptime(start_date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="开始日期格式错误,应为YYYY-MM-DD") + + if end_date: + try: + filters['end_date'] = datetime.strptime(end_date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="结束日期格式错误,应为YYYY-MM-DD") + + # 权限检查:非管理员只能查看自己的业绩 + if current_user.role != "admin": + from app.models import Employee + employee = db.query(Employee).filter(Employee.user_id == current_user.id).first() + if employee: + filters['employee_id'] = employee.id + else: + if employee_id: + filters['employee_id'] = employee_id + + if agent_id: + filters['agent_id'] = agent_id + if category_id: + filters['category_id'] = category_id + if record_type: + filters['record_type'] = record_type + + try: + excel_bytes, filename = export_performance_report( + db=db, + filters=filters + ) + + return StreamingResponse( + iter([excel_bytes]), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..80156d5 --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,88 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.database import get_db +from app.models import Setting +from app.routers.auth import get_current_admin + +router = APIRouter(prefix="/settings", tags=["系统设置"]) + + +# Pydantic模型 +class SettingItem(BaseModel): + key: str + value: str + + +class SettingsUpdate(BaseModel): + settings: List[SettingItem] + + +@router.get("", response_model=dict) +def get_settings(db: Session = Depends(get_db), current_admin=Depends(get_current_admin)): + """获取所有设置(按分组)""" + settings = db.query(Setting).order_by(Setting.group_name, Setting.sort_order).all() + + # 按分组组织 + result = {} + for setting in settings: + group = setting.group_name or "general" + if group not in result: + result[group] = {} + result[group][setting.setting_key] = setting.setting_value + + return { + "code": 200, + "message": "success", + "data": result + } + + +@router.put("", response_model=dict) +def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_admin=Depends(get_current_admin)): + """批量更新设置""" + updated_count = 0 + + for item in data.settings: + setting = db.query(Setting).filter(Setting.setting_key == item.key).first() + if setting: + setting.setting_value = item.value + updated_count += 1 + else: + # 如果不存在则创建 + new_setting = Setting( + setting_key=item.key, + setting_value=item.value, + description="", + group_name="general" + ) + db.add(new_setting) + updated_count += 1 + + db.commit() + + return { + "code": 200, + "message": f"成功更新 {updated_count} 项设置", + "data": None + } + + +@router.get("/value/{key}", response_model=dict) +def get_setting_value(key: str, db: Session = Depends(get_db), current_admin=Depends(get_current_admin)): + """获取单个设置值""" + setting = db.query(Setting).filter(Setting.setting_key == key).first() + if not setting: + raise HTTPException(status_code=404, detail="设置项不存在") + + return { + "code": 200, + "message": "success", + "data": { + "key": setting.setting_key, + "value": setting.setting_value, + "description": setting.description + } + } diff --git a/backend/app/services/__pycache__/calculate_service.cpython-312.pyc b/backend/app/services/__pycache__/calculate_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c3007e76791f6d0130943d842c8e7334ce4fea3c GIT binary patch literal 23876 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 literal 0 HcmV?d00001 diff --git a/backend/app/services/calculate_service.py b/backend/app/services/calculate_service.py new file mode 100644 index 0000000..d3eeede --- /dev/null +++ b/backend/app/services/calculate_service.py @@ -0,0 +1,604 @@ +from datetime import date, datetime +from decimal import Decimal +from typing import Optional, Dict, List, Any +from sqlalchemy.orm import Session +from sqlalchemy import func, and_ +import json + +from app.models import ( + Employee, SecondaryAgent, ProductCategory, PerformanceRecord, + CalculationResult, User +) + + +def get_period_dates(period: str, year: int, month: Optional[int] = None, + quarter: Optional[int] = None) -> tuple: + """ + 根据周期类型获取开始和结束日期 + + Returns: + tuple: (start_date, end_date, period_key) + """ + if period == "monthly": + if month is None: + raise ValueError("月度周期需要提供month参数") + start_date = date(year, month, 1) + if month == 12: + end_date = date(year + 1, 1, 1) + else: + end_date = date(year, month + 1, 1) + period_key = f"{year}-{month:02d}" + + elif period == "quarterly": + if quarter is None: + raise ValueError("季度周期需要提供quarter参数") + start_month = (quarter - 1) * 3 + 1 + end_month = quarter * 3 + 1 + start_date = date(year, start_month, 1) + if end_month > 12: + end_date = date(year + 1, 1, 1) + else: + end_date = date(year, end_month, 1) + period_key = f"{year}-Q{quarter}" + + elif period == "half_yearly": + start_date = date(year, 1, 1) + end_date = date(year, 7, 1) + period_key = f"{year}-H1" + + elif period == "yearly": + start_date = date(year, 1, 1) + end_date = date(year + 1, 1, 1) + period_key = f"{year}" + + else: + raise ValueError(f"不支持的周期类型: {period}") + + return start_date, end_date, period_key + + +def get_target_by_period(employee: Employee, period: str) -> Decimal: + """根据周期类型获取员工目标""" + if period == "monthly": + return employee.monthly_target or Decimal("0") + elif period == "quarterly": + return employee.quarterly_target or Decimal("0") + elif period == "half_yearly": + return employee.half_year_target or Decimal("0") + elif period == "yearly": + return employee.yearly_target or Decimal("0") + return Decimal("0") + + +def get_rebate_rate_by_period(category: ProductCategory, period: str) -> Decimal: + """根据周期类型获取返点比例""" + if period == "monthly": + return category.monthly_rebate or Decimal("0") + elif period == "quarterly": + return category.quarterly_rebate or category.monthly_rebate or Decimal("0") + elif period == "half_yearly": + return category.quarterly_rebate or category.monthly_rebate or Decimal("0") + elif period == "yearly": + return category.quarterly_rebate or category.monthly_rebate or Decimal("0") + return Decimal("0") + + +def calculate_employee_income( + db: Session, + employee_id: int, + period: str, + year: int, + month: Optional[int] = None, + quarter: Optional[int] = None, + save_result: bool = True +) -> Dict[str, Any]: + """ + 计算员工在指定周期的收益 + + 计算逻辑: + 1. 获取员工业绩数据(根据period筛选) + 2. 计算完成率 = 总业绩 / 目标 + 3. 绩效奖金 = 1000 * min(完成率, 1.0) 如果完成率>=50%,否则0 + 4. 个人提成 = 业绩按分类汇总 * 各分类提成比例 + 5. 代理提成 = 二级代理业绩总和 * 0.01 + 6. 总收入 = 底薪 + 绩效奖金 + 个人提成 + 代理提成 + + Args: + db: 数据库会话 + employee_id: 员工ID + period: 周期类型 (monthly/quarterly/half_yearly/yearly) + year: 年份 + month: 月份(月度周期需要) + quarter: 季度(季度周期需要) + save_result: 是否保存计算结果到数据库 + + Returns: + 计算结果字典 + """ + # 获取员工信息 + employee = db.query(Employee).filter(Employee.id == employee_id).first() + if not employee: + raise ValueError(f"员工不存在: {employee_id}") + + # 获取周期日期范围 + start_date, end_date, period_key = get_period_dates(period, year, month, quarter) + + # 获取目标金额 + target_amount = get_target_by_period(employee, period) + + # 1. 获取员工业绩数据(个人业绩) + personal_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.employee_id == employee_id, + PerformanceRecord.record_type == "employee", + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + # 按分类汇总个人业绩 + category_performance = {} + total_personal_performance = Decimal("0") + + for record in personal_records: + category_id = record.category_id + amount = record.amount or Decimal("0") + total_personal_performance += amount + + if category_id not in category_performance: + category_performance[category_id] = { + "amount": Decimal("0"), + "category_name": record.category.name if record.category else "未知分类" + } + category_performance[category_id]["amount"] += amount + + # 2. 获取二级代理业绩数据 + agent_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.employee_id == employee_id, + PerformanceRecord.record_type == "agent", + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + total_agent_performance = Decimal("0") + for record in agent_records: + total_agent_performance += record.amount or Decimal("0") + + # 计算总业绩 + total_performance = total_personal_performance + total_agent_performance + + # 3. 计算完成率 + completion_rate = Decimal("0") + if target_amount > 0: + completion_rate = (total_performance / target_amount) * 100 + + # 4. 计算绩效奖金 + performance_bonus = Decimal("0") + if completion_rate >= 50: + rate = min(completion_rate / 100, Decimal("1.0")) + performance_bonus = Decimal("1000") * rate + + # 5. 计算个人提成 + personal_commission = Decimal("0") + commission_details = [] + + for category_id, data in category_performance.items(): + category = db.query(ProductCategory).filter(ProductCategory.id == category_id).first() + if category: + commission_rate = category.commission_rate or Decimal("0") + commission = data["amount"] * commission_rate + personal_commission += commission + commission_details.append({ + "category_id": category_id, + "category_name": data["category_name"], + "amount": float(data["amount"]), + "commission_rate": float(commission_rate), + "commission": float(commission) + }) + + # 6. 计算代理提成(二级代理业绩的1%) + agent_commission_rate = Decimal("0.01") + agent_commission = total_agent_performance * agent_commission_rate + + # 7. 计算总收入 + base_salary = employee.base_salary or Decimal("0") + total_income = base_salary + performance_bonus + personal_commission + agent_commission + + # 计算公司相关数据 + company_rebate = Decimal("0") + for category_id, data in category_performance.items(): + category = db.query(ProductCategory).filter(ProductCategory.id == category_id).first() + if category: + rebate_rate = get_rebate_rate_by_period(category, period) + company_rebate += data["amount"] * rebate_rate + + # 公司成本 + company_cost = total_income # 员工成本 + + # 代理分成成本 + agent_share_cost = Decimal("0") + for record in agent_records: + if record.agent: + share_rate = record.agent.profit_share_rate or Decimal("0.60") + agent_share_cost += (record.amount or Decimal("0")) * share_rate + + company_cost += agent_share_cost + + # 公司利润 + company_profit = company_rebate - company_cost + + # 构建详情JSON + detail_json = { + "personal_performance": { + "total": float(total_personal_performance), + "by_category": commission_details + }, + "agent_performance": { + "total": float(total_agent_performance), + "commission_rate": float(agent_commission_rate), + "commission": float(agent_commission) + }, + "target": { + "amount": float(target_amount), + "completion_rate": float(completion_rate) + }, + "bonus_calculation": { + "threshold": 50, + "max_bonus": 1000, + "actual_bonus": float(performance_bonus) + } + } + + result = { + "employee_id": employee_id, + "employee_name": employee.user.name if employee.user else "", + "period": period, + "year": year, + "month": month, + "quarter": quarter, + "period_key": period_key, + "period_start_date": start_date.isoformat(), + "period_end_date": end_date.isoformat(), + "total_performance": float(total_performance), + "target_amount": float(target_amount), + "completion_rate": float(completion_rate), + "base_salary": float(base_salary), + "performance_bonus": float(performance_bonus), + "personal_commission": float(personal_commission), + "agent_commission": float(agent_commission), + "total_income": float(total_income), + "company_rebate": float(company_rebate), + "company_cost": float(company_cost), + "company_profit": float(company_profit), + "agent_performance": float(total_agent_performance), + "agent_share_amount": float(agent_share_cost), + "detail": detail_json + } + + # 保存计算结果到数据库 + if save_result: + calc_result = CalculationResult( + employee_id=employee_id, + calc_period=period, + calc_year=year, + calc_month=month, + calc_quarter=quarter, + period_start_date=start_date, + period_end_date=end_date, + total_performance=total_performance, + target_amount=target_amount, + completion_rate=completion_rate, + base_salary=base_salary, + performance_bonus=performance_bonus, + personal_commission=personal_commission, + agent_commission=agent_commission, + total_income=total_income, + company_rebate=company_rebate, + company_cost=company_cost, + company_profit=company_profit, + agent_performance=total_agent_performance, + agent_share_amount=agent_share_cost, + detail_json=json.dumps(detail_json, ensure_ascii=False) + ) + db.add(calc_result) + db.commit() + db.refresh(calc_result) + result["calculation_id"] = calc_result.id + + return result + + +def calculate_company_profit( + db: Session, + period: str, + year: int, + month: Optional[int] = None, + quarter: Optional[int] = None, + save_result: bool = False +) -> Dict[str, Any]: + """ + 计算公司在指定周期的收益 + + 计算逻辑: + 1. 获取所有员工业绩 + 2. 公司返点 = 业绩按分类汇总 * 各分类返点比例 + 3. 公司成本 = 所有员工(底薪+绩效+提成) + 代理分成 + 4. 公司利润 = 返点 - 成本 + + Args: + db: 数据库会话 + period: 周期类型 + year: 年份 + month: 月份 + quarter: 季度 + save_result: 是否保存结果 + + Returns: + 计算结果字典 + """ + # 获取周期日期范围 + start_date, end_date, period_key = get_period_dates(period, year, month, quarter) + + # 获取所有员工 + employees = db.query(Employee).join(User).filter(User.status == 1).all() + + total_company_rebate = Decimal("0") + total_company_cost = Decimal("0") + total_agent_share = Decimal("0") + employee_results = [] + + # 获取所有业绩记录 + all_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + # 按分类汇总业绩 + category_totals = {} + for record in all_records: + if record.record_type == "employee": + cat_id = record.category_id + if cat_id not in category_totals: + category_totals[cat_id] = Decimal("0") + category_totals[cat_id] += record.amount or Decimal("0") + + # 计算公司返点 + rebate_details = [] + for cat_id, amount in category_totals.items(): + category = db.query(ProductCategory).filter(ProductCategory.id == cat_id).first() + if category: + rebate_rate = get_rebate_rate_by_period(category, period) + rebate = amount * rebate_rate + total_company_rebate += rebate + rebate_details.append({ + "category_id": cat_id, + "category_name": category.name, + "amount": float(amount), + "rebate_rate": float(rebate_rate), + "rebate": float(rebate) + }) + + # 计算每个员工的收益和代理分成 + for employee in employees: + try: + emp_result = calculate_employee_income( + db, employee.id, period, year, month, quarter, save_result=False + ) + total_company_cost += Decimal(str(emp_result["total_income"])) + total_agent_share += Decimal(str(emp_result["agent_share_amount"])) + employee_results.append({ + "employee_id": employee.id, + "employee_name": emp_result["employee_name"], + "total_income": emp_result["total_income"], + "agent_share": emp_result["agent_share_amount"] + }) + except Exception as e: + # 跳过计算失败的员工 + continue + + # 总成本 + total_cost = total_company_cost + total_agent_share + + # 公司利润 + company_profit = total_company_rebate - total_cost + + result = { + "period": period, + "year": year, + "month": month, + "quarter": quarter, + "period_key": period_key, + "period_start_date": start_date.isoformat(), + "period_end_date": end_date.isoformat(), + "total_rebate": float(total_company_rebate), + "total_employee_cost": float(total_company_cost), + "total_agent_share": float(total_agent_share), + "total_cost": float(total_cost), + "company_profit": float(company_profit), + "employee_count": len(employee_results), + "rebate_details": rebate_details, + "employee_details": employee_results + } + + return result + + +def calculate_agent_profit( + db: Session, + agent_id: int, + period: str, + year: int, + month: Optional[int] = None, + quarter: Optional[int] = None +) -> Dict[str, Any]: + """ + 计算二级代理在指定周期的收益 + + 计算逻辑: + 代理分成 = 代理业绩 * 代理分佣比例(默认60%) + + Args: + db: 数据库会话 + agent_id: 代理ID + period: 周期类型 + year: 年份 + month: 月份 + quarter: 季度 + + Returns: + 计算结果字典 + """ + # 获取代理信息 + agent = db.query(SecondaryAgent).filter(SecondaryAgent.id == agent_id).first() + if not agent: + raise ValueError(f"代理不存在: {agent_id}") + + # 获取周期日期范围 + start_date, end_date, period_key = get_period_dates(period, year, month, quarter) + + # 获取代理业绩 + agent_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.agent_id == agent_id, + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + total_performance = Decimal("0") + performance_details = [] + + for record in agent_records: + amount = record.amount or Decimal("0") + total_performance += amount + performance_details.append({ + "record_id": record.id, + "date": record.record_date.isoformat() if record.record_date else None, + "amount": float(amount), + "customer_name": record.customer_name, + "order_no": record.order_no + }) + + # 计算代理分成 + profit_share_rate = agent.profit_share_rate or Decimal("0.60") + profit_share_amount = total_performance * profit_share_rate + + result = { + "agent_id": agent_id, + "agent_name": agent.company_name, + "contact_name": agent.contact_name, + "employee_id": agent.employee_id, + "employee_name": agent.employee.user.name if agent.employee and agent.employee.user else "", + "period": period, + "year": year, + "month": month, + "quarter": quarter, + "period_key": period_key, + "period_start_date": start_date.isoformat(), + "period_end_date": end_date.isoformat(), + "total_performance": float(total_performance), + "profit_share_rate": float(profit_share_rate), + "profit_share_amount": float(profit_share_amount), + "performance_count": len(agent_records), + "performance_details": performance_details + } + + return result + + +def get_calculation_history( + db: Session, + employee_id: Optional[int] = None, + period: Optional[str] = None, + year: Optional[int] = None, + page: int = 1, + page_size: int = 20 +) -> Dict[str, Any]: + """获取计算历史列表""" + query = db.query(CalculationResult) + + if employee_id: + query = query.filter(CalculationResult.employee_id == employee_id) + if period: + query = query.filter(CalculationResult.calc_period == period) + if year: + query = query.filter(CalculationResult.calc_year == year) + + total = query.count() + + results = query.order_by( + CalculationResult.calc_year.desc(), + CalculationResult.created_at.desc() + ).offset((page - 1) * page_size).limit(page_size).all() + + items = [] + for result in results: + items.append({ + "id": result.id, + "employee_id": result.employee_id, + "employee_name": result.employee.user.name if result.employee and result.employee.user else "", + "calc_period": result.calc_period, + "calc_year": result.calc_year, + "calc_month": result.calc_month, + "calc_quarter": result.calc_quarter, + "period_start_date": result.period_start_date.isoformat() if result.period_start_date else None, + "period_end_date": result.period_end_date.isoformat() if result.period_end_date else None, + "total_performance": float(result.total_performance) if result.total_performance else 0, + "target_amount": float(result.target_amount) if result.target_amount else 0, + "completion_rate": float(result.completion_rate) if result.completion_rate else 0, + "total_income": float(result.total_income) if result.total_income else 0, + "company_profit": float(result.company_profit) if result.company_profit else 0, + "created_at": result.created_at.isoformat() if result.created_at else None + }) + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size + } + + +def get_calculation_detail(db: Session, calculation_id: int) -> Optional[Dict[str, Any]]: + """获取计算历史详情""" + result = db.query(CalculationResult).filter(CalculationResult.id == calculation_id).first() + if not result: + return None + + detail = None + if result.detail_json: + try: + detail = json.loads(result.detail_json) + except: + detail = None + + return { + "id": result.id, + "employee_id": result.employee_id, + "employee_name": result.employee.user.name if result.employee and result.employee.user else "", + "calc_period": result.calc_period, + "calc_year": result.calc_year, + "calc_month": result.calc_month, + "calc_quarter": result.calc_quarter, + "period_start_date": result.period_start_date.isoformat() if result.period_start_date else None, + "period_end_date": result.period_end_date.isoformat() if result.period_end_date else None, + "total_performance": float(result.total_performance) if result.total_performance else 0, + "target_amount": float(result.target_amount) if result.target_amount else 0, + "completion_rate": float(result.completion_rate) if result.completion_rate else 0, + "base_salary": float(result.base_salary) if result.base_salary else 0, + "performance_bonus": float(result.performance_bonus) if result.performance_bonus else 0, + "personal_commission": float(result.personal_commission) if result.personal_commission else 0, + "agent_commission": float(result.agent_commission) if result.agent_commission else 0, + "total_income": float(result.total_income) if result.total_income else 0, + "company_rebate": float(result.company_rebate) if result.company_rebate else 0, + "company_cost": float(result.company_cost) if result.company_cost else 0, + "company_profit": float(result.company_profit) if result.company_profit else 0, + "agent_performance": float(result.agent_performance) if result.agent_performance else 0, + "agent_share_amount": float(result.agent_share_amount) if result.agent_share_amount else 0, + "detail": detail, + "created_at": result.created_at.isoformat() if result.created_at else None + } diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py new file mode 100644 index 0000000..bde19a3 --- /dev/null +++ b/backend/app/services/report_service.py @@ -0,0 +1,369 @@ +import pandas as pd +import io +from datetime import date, datetime +from typing import Optional, Dict, List, Any +from sqlalchemy.orm import Session +from sqlalchemy import func, and_ + +from app.models import Employee, SecondaryAgent, ProductCategory, PerformanceRecord, User +from app.services.calculate_service import ( + calculate_employee_income, calculate_company_profit, + get_period_dates, get_rebate_rate_by_period +) + + +def export_employee_report( + db: Session, + employee_id: int, + period: str, + year: int, + month: Optional[int] = None +) -> tuple: + """ + 导出员工收益明细Excel + + Returns: + tuple: (excel_bytes, filename) + """ + # 计算员工收益 + result = calculate_employee_income(db, employee_id, period, year, month, save_result=False) + + # 获取周期日期范围 + start_date, end_date, period_key = get_period_dates(period, year, month) + + # 获取业绩明细 + personal_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.employee_id == employee_id, + PerformanceRecord.record_type == "employee", + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + agent_records = db.query(PerformanceRecord).filter( + and_( + PerformanceRecord.employee_id == employee_id, + PerformanceRecord.record_type == "agent", + PerformanceRecord.record_date >= start_date, + PerformanceRecord.record_date < end_date + ) + ).all() + + # 创建Excel writer + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + # Sheet 1: 收益汇总 + summary_data = { + '项目': [ + '员工姓名', + '计算周期', + '业绩总额', + '目标金额', + '完成率(%)', + '底薪', + '绩效奖金', + '个人提成', + '代理提成', + '总收入', + '公司返点', + '公司成本', + '公司利润' + ], + '金额': [ + result['employee_name'], + result['period_key'], + result['total_performance'], + result['target_amount'], + f"{result['completion_rate']:.2f}", + result['base_salary'], + result['performance_bonus'], + result['personal_commission'], + result['agent_commission'], + result['total_income'], + result['company_rebate'], + result['company_cost'], + result['company_profit'] + ] + } + df_summary = pd.DataFrame(summary_data) + df_summary.to_excel(writer, sheet_name='收益汇总', index=False) + + # Sheet 2: 个人业绩明细 + if personal_records: + personal_data = [] + for record in personal_records: + personal_data.append({ + '日期': record.record_date, + '客户名称': record.customer_name or '', + '订单号': record.order_no or '', + '产品分类': record.category.name if record.category else '', + '业绩金额': float(record.amount or 0), + '提成比例': float(record.category.commission_rate or 0) if record.category else 0, + '提成金额': float(record.amount or 0) * float(record.category.commission_rate or 0) if record.category else 0 + }) + df_personal = pd.DataFrame(personal_data) + df_personal.to_excel(writer, sheet_name='个人业绩明细', index=False) + else: + pd.DataFrame({'提示': ['该周期内无个人业绩记录']}).to_excel(writer, sheet_name='个人业绩明细', index=False) + + # Sheet 3: 代理业绩明细 + if agent_records: + agent_data = [] + for record in agent_records: + agent = record.agent + agent_data.append({ + '日期': record.record_date, + '代理公司': agent.company_name if agent else '', + '客户名称': record.customer_name or '', + '订单号': record.order_no or '', + '产品分类': record.category.name if record.category else '', + '业绩金额': float(record.amount or 0), + '分佣比例': float(agent.profit_share_rate or 0.6) if agent else 0.6, + '分佣金额': float(record.amount or 0) * float(agent.profit_share_rate or 0.6) if agent else float(record.amount or 0) * 0.6 + }) + df_agent = pd.DataFrame(agent_data) + df_agent.to_excel(writer, sheet_name='代理业绩明细', index=False) + else: + pd.DataFrame({'提示': ['该周期内无代理业绩记录']}).to_excel(writer, sheet_name='代理业绩明细', index=False) + + # Sheet 4: 提成明细 + if result.get('detail') and result['detail'].get('personal_performance'): + commission_data = [] + for item in result['detail']['personal_performance'].get('by_category', []): + commission_data.append({ + '产品分类': item['category_name'], + '业绩金额': item['amount'], + '提成比例': item['commission_rate'], + '提成金额': item['commission'] + }) + if commission_data: + df_commission = pd.DataFrame(commission_data) + df_commission.to_excel(writer, sheet_name='提成明细', index=False) + + output.seek(0) + filename = f"employee_report_{employee_id}_{period_key}.xlsx" + return output.getvalue(), filename + + +def export_company_report( + db: Session, + period: str, + year: int, + month: Optional[int] = None +) -> tuple: + """ + 导出公司收益汇总Excel + + Returns: + tuple: (excel_bytes, filename) + """ + # 计算公司收益 + result = calculate_company_profit(db, period, year, month) + + # 获取周期日期范围 + start_date, end_date, period_key = get_period_dates(period, year, month) + + # 创建Excel writer + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + # Sheet 1: 公司收益汇总 + summary_data = { + '项目': [ + '计算周期', + '员工数量', + '总返点收入', + '员工成本', + '代理分成', + '总成本', + '公司利润' + ], + '金额': [ + result['period_key'], + result['employee_count'], + result['total_rebate'], + result['total_employee_cost'], + result['total_agent_share'], + result['total_cost'], + result['company_profit'] + ] + } + df_summary = pd.DataFrame(summary_data) + df_summary.to_excel(writer, sheet_name='公司收益汇总', index=False) + + # Sheet 2: 返点明细 + if result.get('rebate_details'): + rebate_data = [] + for item in result['rebate_details']: + rebate_data.append({ + '产品分类': item['category_name'], + '业绩金额': item['amount'], + '返点比例': item['rebate_rate'], + '返点金额': item['rebate'] + }) + df_rebate = pd.DataFrame(rebate_data) + df_rebate.to_excel(writer, sheet_name='返点明细', index=False) + + # Sheet 3: 员工成本明细 + if result.get('employee_details'): + emp_data = [] + for item in result['employee_details']: + emp_data.append({ + '员工ID': item['employee_id'], + '员工姓名': item['employee_name'], + '员工收入': item['total_income'], + '代理分成': item['agent_share'] + }) + df_emp = pd.DataFrame(emp_data) + df_emp.to_excel(writer, sheet_name='员工成本明细', index=False) + + output.seek(0) + filename = f"company_report_{period_key}.xlsx" + return output.getvalue(), filename + + +def export_performance_report( + db: Session, + filters: Dict[str, Any] +) -> tuple: + """ + 导出业绩报表Excel + + Args: + filters: 筛选条件 + - start_date: 开始日期 + - end_date: 结束日期 + - employee_id: 员工ID(可选) + - agent_id: 代理ID(可选) + - category_id: 分类ID(可选) + - record_type: 记录类型(employee/agent) + + Returns: + tuple: (excel_bytes, filename) + """ + # 构建查询 + query = db.query(PerformanceRecord) + + # 应用筛选条件 + if filters.get('start_date'): + query = query.filter(PerformanceRecord.record_date >= filters['start_date']) + if filters.get('end_date'): + query = query.filter(PerformanceRecord.record_date <= filters['end_date']) + if filters.get('employee_id'): + query = query.filter(PerformanceRecord.employee_id == filters['employee_id']) + if filters.get('agent_id'): + query = query.filter(PerformanceRecord.agent_id == filters['agent_id']) + if filters.get('category_id'): + query = query.filter(PerformanceRecord.category_id == filters['category_id']) + if filters.get('record_type'): + query = query.filter(PerformanceRecord.record_type == filters['record_type']) + + records = query.order_by(PerformanceRecord.record_date.desc()).all() + + # 创建Excel writer + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + # Sheet 1: 业绩明细 + if records: + data = [] + total_amount = 0 + for record in records: + amount = float(record.amount or 0) + total_amount += amount + + employee_name = "" + if record.employee and record.employee.user: + employee_name = record.employee.user.name + + agent_name = "" + if record.agent: + agent_name = record.agent.company_name + + category_name = "" + if record.category: + category_name = record.category.name + + data.append({ + 'ID': record.id, + '记录类型': '员工业绩' if record.record_type == 'employee' else '代理业绩', + '日期': record.record_date, + '员工': employee_name, + '代理': agent_name, + '产品分类': category_name, + '客户名称': record.customer_name or '', + '订单号': record.order_no or '', + '业绩金额': amount, + '备注': record.remark or '' + }) + + df = pd.DataFrame(data) + df.to_excel(writer, sheet_name='业绩明细', index=False) + + # Sheet 2: 汇总统计 + summary_data = { + '统计项': [ + '记录总数', + '业绩总额', + '平均单笔业绩', + '员工业绩笔数', + '代理业绩笔数' + ], + '数值': [ + len(records), + total_amount, + round(total_amount / len(records), 2) if records else 0, + len([r for r in records if r.record_type == 'employee']), + len([r for r in records if r.record_type == 'agent']) + ] + } + df_summary = pd.DataFrame(summary_data) + df_summary.to_excel(writer, sheet_name='汇总统计', index=False) + + # Sheet 3: 按员工汇总 + emp_summary = {} + for record in records: + emp_name = record.employee.user.name if record.employee and record.employee.user else '未知' + if emp_name not in emp_summary: + emp_summary[emp_name] = {'count': 0, 'amount': 0} + emp_summary[emp_name]['count'] += 1 + emp_summary[emp_name]['amount'] += float(record.amount or 0) + + emp_data = [] + for name, stats in emp_summary.items(): + emp_data.append({ + '员工': name, + '业绩笔数': stats['count'], + '业绩总额': stats['amount'] + }) + df_emp = pd.DataFrame(emp_data) + df_emp.to_excel(writer, sheet_name='按员工汇总', index=False) + + # Sheet 4: 按分类汇总 + cat_summary = {} + for record in records: + cat_name = record.category.name if record.category else '未知' + if cat_name not in cat_summary: + cat_summary[cat_name] = {'count': 0, 'amount': 0} + cat_summary[cat_name]['count'] += 1 + cat_summary[cat_name]['amount'] += float(record.amount or 0) + + cat_data = [] + for name, stats in cat_summary.items(): + cat_data.append({ + '产品分类': name, + '业绩笔数': stats['count'], + '业绩总额': stats['amount'] + }) + df_cat = pd.DataFrame(cat_data) + df_cat.to_excel(writer, sheet_name='按分类汇总', index=False) + + else: + pd.DataFrame({'提示': ['无业绩记录']}).to_excel(writer, sheet_name='业绩明细', index=False) + + output.seek(0) + + # 生成文件名 + date_str = datetime.now().strftime('%Y%m%d') + filename = f"performance_report_{date_str}.xlsx" + + return output.getvalue(), filename diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 0000000..bbbaa88 --- /dev/null +++ b/backend/init_db.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +""" +数据库初始化脚本 +""" +import sys +import os + +# 添加项目路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.database import init_db, SessionLocal +from app.init_data import init_default_data + + +def main(): + print("🚀 开始初始化数据库...") + print("=" * 50) + + # 1. 创建表结构 + print("\n📦 创建数据库表...") + init_db() + print("✅ 数据库表创建完成") + + # 2. 初始化默认数据 + print("\n📝 初始化默认数据...") + db = SessionLocal() + try: + init_default_data(db) + finally: + db.close() + + print("\n" + "=" * 50) + print("🎉 数据库初始化完成!") + print("\n你可以使用以下命令启动后端服务:") + print(" uvicorn app.main:app --reload") + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2e29268 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.109.0 +uvicorn==0.27.0 +sqlalchemy==2.0.25 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +pandas==2.1.4 +openpyxl==3.1.2 +reportlab==4.0.9 +python-dateutil==2.8.2 +aiosqlite==0.19.0 diff --git a/backend/sales_management.db b/backend/sales_management.db new file mode 100644 index 0000000000000000000000000000000000000000..c02276716f1010ab844385446703be3cd70b874c GIT binary patch literal 90112 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 + + + + + + 销售业绩与收益计算管理系统 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b180898 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1938 @@ +{ + "name": "sales-management-system", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sales-management-system", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.6.0", + "echarts": "^5.6.0", + "element-plus": "^2.5.0", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-echarts": "^6.6.0", + "vue-router": "^4.2.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.13.6", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.6.tgz", + "integrity": "sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/resize-detector": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/resize-detector/-/resize-detector-0.3.0.tgz", + "integrity": "sha512-R/tCuvuOHQ8o2boRP6vgx8hXCCy87H1eY9V5imBYeVNyNVpuL9ciReSccLj2gDcax9+2weXy3bc8Vv+NRXeEvQ==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "6.7.3", + "resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-6.7.3.tgz", + "integrity": "sha512-vXLKpALFjbPphW9IfQPOVfb1KjGZ/f8qa/FZHi9lZIWzAnQC1DgnmEK3pJgEkyo6EP7UnX6Bv/V3Ke7p+qCNXA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "resize-detector": "^0.3.0", + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.5", + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.4.1", + "vue": "^2.6.12 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "@vue/runtime-core": { + "optional": true + } + } + }, + "node_modules/vue-echarts/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..33aa272 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "sales-management-system", + "version": "1.0.0", + "description": "销售业绩与收益计算管理系统", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.6.0", + "echarts": "^5.6.0", + "element-plus": "^2.5.0", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-echarts": "^6.6.0", + "vue-router": "^4.2.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..8a00770 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/frontend/src/api/agents.js b/frontend/src/api/agents.js new file mode 100644 index 0000000..9a41c84 --- /dev/null +++ b/frontend/src/api/agents.js @@ -0,0 +1,44 @@ +import request from '@/utils/request' + +// 获取代理列表 +export function getAgentList(params) { + return request({ + url: '/agents', + method: 'get', + params + }) +} + +// 获取代理详情 +export function getAgentDetail(id) { + return request({ + url: `/agents/${id}`, + method: 'get' + }) +} + +// 创建代理 +export function createAgent(data) { + return request({ + url: '/agents', + method: 'post', + data + }) +} + +// 更新代理 +export function updateAgent(id, data) { + return request({ + url: `/agents/${id}`, + method: 'put', + data + }) +} + +// 删除代理 +export function deleteAgent(id) { + return request({ + url: `/agents/${id}`, + method: 'delete' + }) +} diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..af1fef8 --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,34 @@ +import request from '@/utils/request' + +// 登录 +export function login(data) { + // 使用form-data格式,符合OAuth2标准 + const formData = new URLSearchParams() + formData.append('username', data.username) + formData.append('password', data.password) + + return request({ + url: '/auth/login', + method: 'post', + data: formData.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) +} + +// 获取用户信息 +export function getUserInfo() { + return request({ + url: '/auth/me', + method: 'get' + }) +} + +// 退出登录 +export function logout() { + return request({ + url: '/auth/logout', + method: 'post' + }) +} diff --git a/frontend/src/api/calculate.js b/frontend/src/api/calculate.js new file mode 100644 index 0000000..e39f987 --- /dev/null +++ b/frontend/src/api/calculate.js @@ -0,0 +1,53 @@ +import request from '@/utils/request' + +// 计算员工收益 +export function calculateEmployee(data) { + return request({ + url: '/calculate/employee', + method: 'post', + data + }) +} + +// 计算公司收益 +export function calculateCompany(data) { + return request({ + url: '/calculate/company', + method: 'post', + data + }) +} + +// 计算代理收益 +export function calculateAgent(agentId, data) { + return request({ + url: `/calculate/agent/${agentId}`, + method: 'post', + data + }) +} + +// 获取计算历史 +export function getCalculationHistory(params) { + return request({ + url: '/calculate/history', + method: 'get', + params + }) +} + +// 获取计算历史详情 +export function getCalculationDetail(id) { + return request({ + url: `/calculate/history/${id}`, + method: 'get' + }) +} + +// 保存计算结果 +export function saveCalculationResult(id) { + return request({ + url: `/calculate/history/${id}/save`, + method: 'post' + }) +} diff --git a/frontend/src/api/categories.js b/frontend/src/api/categories.js new file mode 100644 index 0000000..c60bd36 --- /dev/null +++ b/frontend/src/api/categories.js @@ -0,0 +1,62 @@ +import request from '@/utils/request' + +// 获取分类列表 +export function getCategoryList(params) { + return request({ + url: '/categories', + method: 'get', + params + }) +} + +// 获取分类详情 +export function getCategoryDetail(id) { + return request({ + url: `/categories/${id}`, + method: 'get' + }) +} + +// 创建分类 +export function createCategory(data) { + return request({ + url: '/categories', + method: 'post', + data + }) +} + +// 更新分类 +export function updateCategory(id, data) { + return request({ + url: `/categories/${id}`, + method: 'put', + data + }) +} + +// 删除分类 +export function deleteCategory(id) { + return request({ + url: `/categories/${id}`, + method: 'delete' + }) +} + +// 预览导入 +export function previewImport(data) { + return request({ + url: '/categories/import', + method: 'post', + data + }) +} + +// 确认导入 +export function confirmImport(data) { + return request({ + url: '/categories/import/confirm', + method: 'post', + params: data // batch_id作为查询参数 + }) +} diff --git a/frontend/src/api/dashboard.js b/frontend/src/api/dashboard.js new file mode 100644 index 0000000..6638cac --- /dev/null +++ b/frontend/src/api/dashboard.js @@ -0,0 +1,18 @@ +import request from '@/utils/request' + +// 获取仪表盘汇总数据 +export function getDashboardSummary() { + return request({ + url: '/dashboard/summary', + method: 'get' + }) +} + +// 获取图表数据 +export function getDashboardChart(type, params = {}) { + return request({ + url: '/dashboard/chart', + method: 'get', + params: { type, ...params } + }) +} diff --git a/frontend/src/api/employees.js b/frontend/src/api/employees.js new file mode 100644 index 0000000..63ceb42 --- /dev/null +++ b/frontend/src/api/employees.js @@ -0,0 +1,53 @@ +import request from '@/utils/request' + +// 获取员工列表 +export function getEmployeeList(params) { + return request({ + url: '/employees', + method: 'get', + params + }) +} + +// 获取员工详情 +export function getEmployeeDetail(id) { + return request({ + url: `/employees/${id}`, + method: 'get' + }) +} + +// 创建员工 +export function createEmployee(data) { + return request({ + url: '/employees', + method: 'post', + data + }) +} + +// 更新员工 +export function updateEmployee(id, data) { + return request({ + url: `/employees/${id}`, + method: 'put', + data + }) +} + +// 删除员工 +export function deleteEmployee(id) { + return request({ + url: `/employees/${id}`, + method: 'delete' + }) +} + +// 更新员工目标 +export function updateEmployeeTargets(id, data) { + return request({ + url: `/employees/${id}/targets`, + method: 'put', + data + }) +} diff --git a/frontend/src/api/performance.js b/frontend/src/api/performance.js new file mode 100644 index 0000000..18a10ae --- /dev/null +++ b/frontend/src/api/performance.js @@ -0,0 +1,45 @@ +import request from '@/utils/request' + +// 获取业绩列表 +export function getPerformanceList(params) { + return request({ + url: '/performance', + method: 'get', + params + }) +} + +// 创建业绩 +export function createPerformance(data) { + return request({ + url: '/performance', + method: 'post', + data + }) +} + +// 更新业绩 +export function updatePerformance(id, data) { + return request({ + url: `/performance/${id}`, + method: 'put', + data + }) +} + +// 删除业绩 +export function deletePerformance(id) { + return request({ + url: `/performance/${id}`, + method: 'delete' + }) +} + +// 批量导入业绩 +export function importPerformance(data) { + return request({ + url: '/performance/import', + method: 'post', + data + }) +} diff --git a/frontend/src/api/settings.js b/frontend/src/api/settings.js new file mode 100644 index 0000000..64e9c19 --- /dev/null +++ b/frontend/src/api/settings.js @@ -0,0 +1,26 @@ +import request from '@/utils/request' + +// 获取所有设置 +export function getSettings() { + return request({ + url: '/settings', + method: 'get' + }) +} + +// 获取单个设置 +export function getSetting(key) { + return request({ + url: `/settings/${key}`, + method: 'get' + }) +} + +// 更新设置 +export function updateSetting(key, value) { + return request({ + url: `/settings/${key}`, + method: 'put', + data: { value } + }) +} diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue new file mode 100644 index 0000000..863efce --- /dev/null +++ b/frontend/src/components/Header.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue new file mode 100644 index 0000000..3c46c74 --- /dev/null +++ b/frontend/src/components/Layout.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue new file mode 100644 index 0000000..8b2cdd4 --- /dev/null +++ b/frontend/src/components/Sidebar.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..f96c95e --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,22 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' + +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..cd22679 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,87 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useUserStore } from '@/store/modules/user' +import Layout from '@/components/Layout.vue' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { public: true } + }, + { + path: '/', + component: Layout, + redirect: '/dashboard', + children: [ + { + path: '/dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { title: '仪表盘', icon: 'DataLine' } + }, + { + path: '/employees', + name: 'Employees', + component: () => import('@/views/employees/EmployeeList.vue'), + meta: { title: '员工管理', icon: 'User' } + }, + { + path: '/agents', + name: 'Agents', + component: () => import('@/views/agents/AgentList.vue'), + meta: { title: '二级代理', icon: 'UserFilled' } + }, + { + path: '/categories', + name: 'Categories', + component: () => import('@/views/categories/CategoryList.vue'), + meta: { title: '产品分类', icon: 'Grid' } + }, + { + path: '/performance', + name: 'Performance', + component: () => import('@/views/performance/PerformanceList.vue'), + meta: { title: '业绩录入', icon: 'Edit' } + }, + { + path: '/calculate', + name: 'CalculateEmployee', + component: () => import('@/views/calculate/CalculateEmployee.vue'), + meta: { title: '员工收益计算', icon: 'Calculator' } + }, + { + path: '/calculate/company', + name: 'CalculateCompany', + component: () => import('@/views/calculate/CalculateCompany.vue'), + meta: { title: '公司收益计算', icon: 'OfficeBuilding', adminOnly: true } + }, + { + path: '/settings', + name: 'Settings', + component: () => import('@/views/settings/Settings.vue'), + meta: { title: '设置中心', icon: 'Setting' } + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 - 权限控制 +router.beforeEach((to, from, next) => { + const userStore = useUserStore() + + if (!to.meta.public && !userStore.token) { + next('/login') + } else if (to.path === '/login' && userStore.token) { + next('/') + } else { + next() + } +}) + +export default router diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js new file mode 100644 index 0000000..f00b209 --- /dev/null +++ b/frontend/src/store/index.js @@ -0,0 +1,3 @@ +import { createPinia } from 'pinia' + +export default createPinia() diff --git a/frontend/src/store/modules/app.js b/frontend/src/store/modules/app.js new file mode 100644 index 0000000..5acce48 --- /dev/null +++ b/frontend/src/store/modules/app.js @@ -0,0 +1,24 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useAppStore = defineStore('app', () => { + // State + const sidebarCollapsed = ref(false) + const loading = ref(false) + + // Actions + const toggleSidebar = () => { + sidebarCollapsed.value = !sidebarCollapsed.value + } + + const setLoading = (status) => { + loading.value = status + } + + return { + sidebarCollapsed, + loading, + toggleSidebar, + setLoading + } +}) diff --git a/frontend/src/store/modules/user.js b/frontend/src/store/modules/user.js new file mode 100644 index 0000000..429eadd --- /dev/null +++ b/frontend/src/store/modules/user.js @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { getToken, setToken, removeToken } from '@/utils/auth' + +export const useUserStore = defineStore('user', () => { + // State + const token = ref(getToken()) + const userInfo = ref(null) + + // Getters + const isLoggedIn = computed(() => !!token.value) + const username = computed(() => userInfo.value?.username || '') + + // Actions + const setTokenValue = (newToken) => { + token.value = newToken + setToken(newToken) + } + + const setUserInfo = (info) => { + userInfo.value = info + } + + const logout = () => { + token.value = null + userInfo.value = null + removeToken() + } + + return { + token, + userInfo, + isLoggedIn, + username, + setTokenValue, + setUserInfo, + logout + } +}) diff --git a/frontend/src/utils/auth.js b/frontend/src/utils/auth.js new file mode 100644 index 0000000..bd18b21 --- /dev/null +++ b/frontend/src/utils/auth.js @@ -0,0 +1,17 @@ +const TOKEN_KEY = 'sales_management_token' + +export const getToken = () => { + return localStorage.getItem(TOKEN_KEY) +} + +export const setToken = (token) => { + localStorage.setItem(TOKEN_KEY, token) +} + +export const removeToken = () => { + localStorage.removeItem(TOKEN_KEY) +} + +export const isAuthenticated = () => { + return !!getToken() +} diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js new file mode 100644 index 0000000..fe6ff2a --- /dev/null +++ b/frontend/src/utils/request.js @@ -0,0 +1,72 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import { useUserStore } from '@/store/modules/user' + +// 创建axios实例 +const request = axios.create({ + baseURL: '/api/v1', + timeout: 10000, + headers: { + 'Content-Type': 'application/json' + } +}) + +// 请求拦截器 +request.interceptors.request.use( + (config) => { + const userStore = useUserStore() + if (userStore.token) { + config.headers.Authorization = `Bearer ${userStore.token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 +request.interceptors.response.use( + (response) => { + const { data } = response + + // 根据后端返回的状态码处理 + if (data.code !== 200) { + ElMessage.error(data.message || '请求失败') + return Promise.reject(new Error(data.message)) + } + + return data + }, + (error) => { + const { response } = error + + if (response) { + switch (response.status) { + case 401: + ElMessage.error('登录已过期,请重新登录') + const userStore = useUserStore() + userStore.logout() + window.location.href = '/login' + break + case 403: + ElMessage.error('没有权限访问') + break + case 404: + ElMessage.error('请求的资源不存在') + break + case 500: + ElMessage.error('服务器错误') + break + default: + ElMessage.error(response.data?.message || '网络错误') + } + } else { + ElMessage.error('网络连接失败') + } + + return Promise.reject(error) + } +) + +export default request diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..8a7c833 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..7e8fe3b --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/views/agents/AgentForm.vue b/frontend/src/views/agents/AgentForm.vue new file mode 100644 index 0000000..c403371 --- /dev/null +++ b/frontend/src/views/agents/AgentForm.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/frontend/src/views/agents/AgentList.vue b/frontend/src/views/agents/AgentList.vue new file mode 100644 index 0000000..5a61b8b --- /dev/null +++ b/frontend/src/views/agents/AgentList.vue @@ -0,0 +1,268 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/calculate/CalculateCompany.vue b/frontend/src/views/calculate/CalculateCompany.vue new file mode 100644 index 0000000..01d4f0d --- /dev/null +++ b/frontend/src/views/calculate/CalculateCompany.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/frontend/src/views/calculate/CalculateEmployee.vue b/frontend/src/views/calculate/CalculateEmployee.vue new file mode 100644 index 0000000..2674e80 --- /dev/null +++ b/frontend/src/views/calculate/CalculateEmployee.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/frontend/src/views/categories/CategoryForm.vue b/frontend/src/views/categories/CategoryForm.vue new file mode 100644 index 0000000..6f2bdd8 --- /dev/null +++ b/frontend/src/views/categories/CategoryForm.vue @@ -0,0 +1,205 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/categories/CategoryImport.vue b/frontend/src/views/categories/CategoryImport.vue new file mode 100644 index 0000000..dd44809 --- /dev/null +++ b/frontend/src/views/categories/CategoryImport.vue @@ -0,0 +1,610 @@ + + + + + diff --git a/frontend/src/views/categories/CategoryList.vue b/frontend/src/views/categories/CategoryList.vue new file mode 100644 index 0000000..60a46e0 --- /dev/null +++ b/frontend/src/views/categories/CategoryList.vue @@ -0,0 +1,289 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/employees/EmployeeDetail.vue b/frontend/src/views/employees/EmployeeDetail.vue new file mode 100644 index 0000000..9d98f6d --- /dev/null +++ b/frontend/src/views/employees/EmployeeDetail.vue @@ -0,0 +1,51 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/employees/EmployeeForm.vue b/frontend/src/views/employees/EmployeeForm.vue new file mode 100644 index 0000000..530ff9b --- /dev/null +++ b/frontend/src/views/employees/EmployeeForm.vue @@ -0,0 +1,234 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/employees/EmployeeList.vue b/frontend/src/views/employees/EmployeeList.vue new file mode 100644 index 0000000..a2b5114 --- /dev/null +++ b/frontend/src/views/employees/EmployeeList.vue @@ -0,0 +1,267 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/performance/PerformanceList.vue b/frontend/src/views/performance/PerformanceList.vue new file mode 100644 index 0000000..1d5b2a2 --- /dev/null +++ b/frontend/src/views/performance/PerformanceList.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/frontend/src/views/settings/Settings.vue b/frontend/src/views/settings/Settings.vue new file mode 100644 index 0000000..9425f9b --- /dev/null +++ b/frontend/src/views/settings/Settings.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..2056e13 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '/api') + } + } + } +})