From 0d5e28c78550fdb0e193dd7dc85e846a6253fa35 Mon Sep 17 00:00:00 2001 From: Eamon <17516219072@163.com> Date: Thu, 30 Jul 2026 13:55:28 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 4 +- bun.lock | 5 + package.json | 71 +-- public/favicon.svg | 10 +- public/icons.svg | 24 - src/admin/assets/b10.webp | Bin 0 -> 1082 bytes src/admin/assets/b11.webp | Bin 0 -> 1228 bytes src/admin/assets/b12.webp | Bin 0 -> 1132 bytes src/admin/assets/b3.webp | Bin 0 -> 884 bytes src/admin/assets/b4.webp | Bin 0 -> 980 bytes src/admin/assets/b5.webp | Bin 0 -> 958 bytes src/admin/assets/b6.webp | Bin 0 -> 996 bytes src/admin/assets/b7.webp | Bin 0 -> 1008 bytes src/admin/assets/b8.webp | Bin 0 -> 964 bytes src/admin/assets/b9.webp | Bin 0 -> 1104 bytes src/admin/assets/banner.webp | Bin 0 -> 18322 bytes src/admin/assets/banner2.webp | Bin 0 -> 13512 bytes src/admin/channels/page.tsx | 5 + src/admin/clients.tsx | 250 ++++++----- src/admin/coupon/page.tsx | 5 + src/admin/dashboard/page.tsx | 412 +++++++++++++++++- src/admin/effects.tsx | 59 +++ src/admin/funds/page.tsx | 5 + src/admin/layout-context.tsx | 27 ++ src/admin/layout.tsx | 78 ++-- src/admin/whitelist/page.tsx | 5 + src/api/auth.ts | 101 +++++ src/api/base.ts | 158 +++++++ src/components/authGuard.tsx | 63 +++ .../composites/dialogs/editInfoDialog.tsx | 149 +++++++ src/components/data-table.tsx | 123 ++++++ src/components/ui/dialog.tsx | 22 +- src/components/ui/label.tsx | 22 + src/components/ui/pagination.tsx | 304 +++++++++++++ src/components/ui/select.tsx | 192 ++++++++ src/components/ui/table.tsx | 114 +++++ src/components/ui/tooltip.tsx | 23 +- src/components/wrap.tsx | 2 +- src/home/header.tsx | 2 +- src/home/index.tsx | 7 + src/home/layout.tsx | 2 +- src/lib/api.ts | 51 +++ src/lib/cap/index.ts | 13 + src/routers/index.tsx | 20 +- tsconfig.app.json | 52 ++- tsconfig.json | 4 +- tsconfig.node.json | 40 +- vite.config.ts | 11 +- 48 files changed, 2141 insertions(+), 294 deletions(-) delete mode 100644 public/icons.svg create mode 100644 src/admin/assets/b10.webp create mode 100644 src/admin/assets/b11.webp create mode 100644 src/admin/assets/b12.webp create mode 100644 src/admin/assets/b3.webp create mode 100644 src/admin/assets/b4.webp create mode 100644 src/admin/assets/b5.webp create mode 100644 src/admin/assets/b6.webp create mode 100644 src/admin/assets/b7.webp create mode 100644 src/admin/assets/b8.webp create mode 100644 src/admin/assets/b9.webp create mode 100644 src/admin/assets/banner.webp create mode 100644 src/admin/assets/banner2.webp create mode 100644 src/admin/channels/page.tsx create mode 100644 src/admin/coupon/page.tsx create mode 100644 src/admin/effects.tsx create mode 100644 src/admin/funds/page.tsx create mode 100644 src/admin/layout-context.tsx create mode 100644 src/admin/whitelist/page.tsx create mode 100644 src/api/auth.ts create mode 100644 src/api/base.ts create mode 100644 src/components/authGuard.tsx create mode 100644 src/components/composites/dialogs/editInfoDialog.tsx create mode 100644 src/components/data-table.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/pagination.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/lib/api.ts create mode 100644 src/lib/cap/index.ts diff --git a/.env b/.env index 0f15930..415afbc 100644 --- a/.env +++ b/.env @@ -1 +1,3 @@ -VITE_API_BASE_URL=http://192.168.0.15:8080 \ No newline at end of file +VITE_API_BASE_URL=http://192.168.0.15:8080 +VITE_CLIENT_ID=web +VITE_CLIENT_SECRET=web \ No newline at end of file diff --git a/bun.lock b/bun.lock index 0d8c165..e6aeef8 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@fontsource-variable/geist": "^5.2.9", "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.17.0", @@ -342,6 +343,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.0.tgz", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "https://registry.npmmirror.com/@tanstack/react-table/-/react-table-8.21.3.tgz", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "https://registry.npmmirror.com/@tanstack/table-core/-/table-core-8.21.3.tgz", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], diff --git a/package.json b/package.json index c523bea..b4da69b 100644 --- a/package.json +++ b/package.json @@ -1,37 +1,38 @@ { - "name": "juip-web", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite --host", - "build": "tsc -b && vite build", - "lint": "biome check --write" - }, - "dependencies": { - "@fontsource-variable/geist": "^5.2.9", - "@tailwindcss/vite": "^4.3.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^1.17.0", - "radix-ui": "^1.5.0", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "react-router-dom": "^7.17.0", - "shadcn": "^4.11.0", - "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.3.0", - "tw-animate-css": "^1.4.0" - }, - "devDependencies": { - "@biomejs/biome": "2.4.16", - "@types/node": "^25.9.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "globals": "^17.6.0", - "typescript": "~6.0.2", - "vite": "^8.0.12" - }, - "packageManager": "bun@1.3.2" + "name": "juip-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host", + "build": "tsc -b && vite build", + "lint": "biome check --write" + }, + "dependencies": { + "@fontsource-variable/geist": "^5.2.9", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-table": "^8.21.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.17.0", + "radix-ui": "^1.5.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.17.0", + "shadcn": "^4.11.0", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@biomejs/biome": "2.4.16", + "@types/node": "^25.9.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "vite": "^8.0.12" + }, + "packageManager": "bun@1.3.2" } diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..8a5c70d 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1,9 @@ - \ No newline at end of file + + + + + + + + + diff --git a/public/icons.svg b/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/admin/assets/b10.webp b/src/admin/assets/b10.webp new file mode 100644 index 0000000000000000000000000000000000000000..529b7d856770ec8a37e41d4914b811fa2633d6b6 GIT binary patch literal 1082 zcmWIYbaOLeVPFV%bqWXzu!!JdU|=u+Vtp|3baoCn!3g9sFrEOBEI_H;ypp0IcPE92 z$S4K&2Mmlr2nLDC#RU)+RuZV{+ck*V%eq)q;1}dfNi9wWD)|7!Dn&rcfo>83vPIG% zYz+`Q3BtAkvCB&eN`PttK=2?(*InG6iuTNxN6ZXv`}7BDdI zw=ghlJC6`Ugh@eSQ6ktlU~DijrKK@2d|J-Hz#GZHAbf#=fh!s;2PAN~O#vt+19VqD z5T-NaFr+dlFnBWLF(fnS0a-@CNHt_I1IDnA3llJE^$&0h#2>QSCaNwTvtP$(VQs{T zSzTusZ*iFPs-!NtSiFAP;^UHix6LL!S-^F4=l7k4|902qav$(p%FN2*ct?M!-K4rn zc0gFSeXFH;!{Q?#tef^GKP1u6dA&^8>FSWg!yQ}ASULJm`CTpudB`u& z!C=pj9o%5*up!qx_e$A}nIQ0F|Np-we^Nc;99{lP?wEKbt#ry4#&>;t7$H8E*!M$F zQ1GXvEGS$U80Hy&u2%wrnwR-{*Q`Ya1vmP=`BlxWq_pqcq|G(rJND^a7oWC$-lmn3 zU%phCUXBu6_BihHmszP!ybDf0iw`wRz2IYbW<`G86<*<|t8HT6{d`{>qtdth#IH@F z1}FO8ok|GTw6%B>?WmL5;h(Iax%A0PJrPNP(0b0i`PZ&0cRw+^Ec<-g`PP))I=%V@ z4=0^ZeAZ|mRXF{Pe_+JN*=?_1OypyKsatww=6qQz?h`L#pWj@*F1stHVBNHNr2^k9 zrv`axE;ZZq?P2cPz6D&#F)KB-`M+A1q)ZiVx_Kt(9_xW~3+F|DX*N%FUHp4)P^ND# zpG&A;)0eWUl8t?NKYp&ibH%#O+1fy3!lS42`!Bir8E>6+vd2AI_^A7VQ#T78(gk`0 zvKvoCP4!{rc)CpB=9{+0kBP1G^?mAkEuYN&9dJ_m*|d-A&X(`I+*CTz`PJ2%ne*OQ zMDTr)vxzNRcK-E&ly6hMy}dj$ucYVw{|oOVBG=kJk9RT%vw7NDyX@Zi6Is0G(z^ry LyIfc1(_jDqRV87< literal 0 HcmV?d00001 diff --git a/src/admin/assets/b11.webp b/src/admin/assets/b11.webp new file mode 100644 index 0000000000000000000000000000000000000000..cc7bd20e53439e3f4fddf40cc425580801ce0675 GIT binary patch literal 1228 zcmWIYbaOkx!oU#j>J$(bU=hK^z`$St#QI?5>FgYEf)U7NU_1dLS%6Zxc_l?b?oJ93 zkx>fl4;UDM5DXHNiwhtutRzs?w`&l!mvynKz%R&|l3JV$RPq6cRf>R?1KlJ7WQ(Lj z*cu>q5`=97VwaZ`lmOKPfY?PLLC!#S3XrXlj>JwvVkZ|AfzZ zZ((5Ab{-*y2$O=uqC~K9z}R45N=sv4__UmXfj5$YLHGg#16MRy4oKi|n*vZu2I#JQ zAWUb-VMt|AVDMzfV@PJu1G0>Ok!r|b28>~!YfQkX)jz;3F#XVuZMovlHiRE~S3H{aBaXgUS1UDYmZs!rcE){uitJ zp9u}ht8-Z-H{Q6t?_{gG^6Pz`t?jCuzxVmBvN`N@(Ba0BxG=rXGq$Nn-@dWn%kg7E z8p0}dZ*K29EwhX<`+iqQ)dHUb4jXij?DM`V)6~G3a5QeoC1%!++R3MP=H6d&sylbG z7r%gtr&;da@42^a?r+PT_wKL$v%52m*|j@f>}TGdtL*v0^tsjA**DE6sCZ5SA{(~4 z*ASM9r_stfW`?Y%S>hu7PYW(Al?i5K^?$u9bltt_d9N70Wi#-3e_Zu$zt;7w%rpLf z@MXw5{ee5-;P3s1gTh`i8|-_$cl!NG-H*br)J44+qCUT7X|QXZ^e2AF|I`B=8Rynm zmH8T|&)hpNx;c-*xbmwD6DNmxLRo$4Wv&E^(+`B)I)pwjTq)+wTd?)ZwN1Qo3;!?M zxG*I(+=wSn@CozH=1oywMVh0#FFg7?YY*eI2O4J|OzXMWX7TZ1RebU7Cvz`-NR_fV z=yF);@y&`fzm%HWrBV+4TU{1qtJryq2R0uMDb3`(UU$=D+ugPeyb*mDtdsOE1Vk!i zE=rlU$nluuga~HC|XPrj6$v~uTWl{u!XHgYYpYCY=Zm+&E?o_UX|;?Bzg8*)Fz zKMk2(P-Sv!LEz~fHfF9fiqBa-pCdi_*@0)8UAdW_qSFuTJ~&^b_7=x~okQ<>&Ukx8 zu6@$-^5f-I{JjlF4_{o=pMOR+&t5~)H0<2e^|yi~SvE-MYGtR@_&k}i^6~NVjw$C@ zoVYy&f(4%@UA-c+)M%2QUYgzdYi9aK`Chu-l791MW^IU299PY}-&QoPDjy literal 0 HcmV?d00001 diff --git a/src/admin/assets/b12.webp b/src/admin/assets/b12.webp new file mode 100644 index 0000000000000000000000000000000000000000..4e0151c32b240a7abf9f29ef07dcfd96fe5a89d6 GIT binary patch literal 1132 zcmWIYbaP8#VPFV%bqWXzu!!JdU|=u+Vtp|3baoCn!3g9sFrEOBEI_H;ypp0IcPE92 z$S4K&2Mmlr2nLDC#RU)+RuZV{+ck*V%eq)q;1}dfNi9wWD)|7!Dn&rcfo>83vPIG% zYz+`Q3BtAkvCB&eN`PttK=2?(*InG6iuTNxN6ZXv`}7BDdI zw=ghlJC6`Ugh@eSQ6ktlU~DijrKK@2d|J-Hz#GZHAbf#=fh!s;2PAN~O#vt+19VqD z5T-NaFr+dlFnBWLF(fnS0a-@CNHt_I1IDmVDHAYi^$&0hyg#V6ZLI}=R>6*M4?8

PjGtebZd+9`d90}UO9Pr|N6qUzsomChYOhq zEV|+M{@i4NlfNote=K+?y*+0-uk$wciYI^nu&VUf%hpRg(h^$S^Lwjn_H>TiIaUix zB@4Vn|E^5jd@r$iKLghRP1e6VU3P8V%(1(y>&OX*Nh+RQDhG?4c`h4;>Kry`S^EEf zwDm;Rx!%`1{uvkC_6X#kmiKJap8N@0YPn&7$FP0(q$S=BVH2}GuV47I?@C;im5DJr zu=crq$_Z#X90ff!<~6$K*S>Y)*2#aDRtcrAuKAJNrS#~l7N7bH1*N3W-qw}VZ97t# zZM!A7^4zk2{7X_cm5OscQfRcNtE0TxCtJ{b<;4#Nef3vlS%h`^J6BySjh(*mnczm{ zx#7PqmEOJl?{9*}@7yDE`+4fOhO>pThw8jcJEeZV+T;06S;37n1wPB2+0L0(es$ga zSK+pMj#mXA=K1>jO5yIvhkxf>dKs_Y`}x%GEf=>&q{hjl7p|_nA|JH;?emxaz2)y> z0rt%B;6P_MtUmit{`&jZn{Vcr%|4s9`R1Ft-lFyG8|^%QFP#$Pr3obbgq|K|ICfEF z?v#nvfovO>zqokfvrXVOHUqhTu4!+--~9IbN7?J*LwR!1v*crD$wg~jug*OeIJH%U F0RVG4cJlxL literal 0 HcmV?d00001 diff --git a/src/admin/assets/b3.webp b/src/admin/assets/b3.webp new file mode 100644 index 0000000000000000000000000000000000000000..e40c11058fb069dc83ebac70bba3c82f9613819b GIT binary patch literal 884 zcmWIYbaTsLW?%?+bqWXzu!!JdU|=u+Vs$X`baoCn!3g9sFrEOBEI_H;ypp0IcPE92 z$S4K&2Mmlr2nLDC#RU)+RuZV{+ck*V%eq)q;1}dfNi9wWD)|7!Dn&rcfo>83vPIG% zYz+`Q3BtAkvCB&eN`PttK=2?(*InG6iuTNxN6ZXv`}7BDdI zw=ghlJC6`Ugh@eSQ6ktl79h!#md3#FX*mM}ZzKbQ@C612u4u3vkig+K1)!7+&|UdJ zn9h*HkjkLI;K`83kj$V5WElY?)sVpq7{fkwj0_C=>IXOl_BXERU2;b6;C_~vPfgO& zS<;@pVjnIq`+Z*a&^h}e_m=HT&`(IB_LS+`q#=y-}P0>+JIAdy(u0`(N#-|L#-f8`ms;oo)Yt{_~Su zt(1kc?;ZIfwrEG8=BztQ%kvNCJNG}nw9VvfbN2nrP#(?h&o8b#ygTvn@k+VX%%7e6 zyH>9$laV_1H6&_lVVr5^pD5EucPDvXT@rqH_43C+u&rcJ$(bU=hK^z`$St#Oh$=>FgYEf)U7NU_1dLS%6Zxc_l?b?oJ93 zkx>fl4;UDM5DXHNiwhtutRzs?w`&l!mvynKz%R&|l3JV$RPq6cRf>R?1KlJ7WQ(Lj z*cu>q5`=97VwaZ`lmOKPfY?PLLC!#S3XrXlj>JwvVkZ|AfzZ zZ((5Ab{-*y2$O=uqC~K9EI^VeEscTU({cs|-be-p;R_55T+v`TAc4be3P33tpu6&c zFr6WXA(cUa!IL46A(=rB$T9*(sv(0JFou2ZF)}dds~_MLcz$q4uC!;6(eGRTe&5!6 z*L%A_z;lvP=-I|~pY@;eTyQqbxa~2i{JY)1mcE-UiMP%AlKIZOH!b?Zcf)_%+~UO8 zKfkwsfATxvne;Df`_*UoTW>w&y8L#&VJ}afVw-)>4ZSlr-dO~%?UUtNyNoT&t97bK zTB}rSypt;H9Vdrr3Fb-~EG};t83Z^Pl5%dZl8SkhS24#TFa3M`_37pJpDu6g_-9>k z+iX(LmpLWekqzG@ZVGuO=gCKtrM(@0Cj6iEF$&k~6mp!@>}WPUzpe*fpoR?9J@0HTSjg3TK)vmSUP#_voKMwwJPkL3fJqf)L%`(c5lFz13-nch>d) zz~JAeXj5m^;%O08w)J?#thZN^mmRkGR?}#m$(eim|HJi1+IMXCp1Ds+gFB<|fgRIl z!RYeb{3V_4n^w=rtbBFq*Mj$HaeMuiUw(O@^qaS>@C!>zwwkN9>a;9wh9In%ApXA9o+rP7~W@P{X DmP{=( literal 0 HcmV?d00001 diff --git a/src/admin/assets/b5.webp b/src/admin/assets/b5.webp new file mode 100644 index 0000000000000000000000000000000000000000..6d62048a1c0b6b37a228861b3344044e2faed36b GIT binary patch literal 958 zcmWIYbaUIr%)k)t>J$(bU=hK^z`$St#Oh$=>FgYEf)U7NU_1dLS%6Zxc_l?b?oJ93 zkx>fl4;UDM5DXHNiwhtutRzs?w`&l!mvynKz%R&|l3JV$RPq6cRf>R?1KlJ7WQ(Lj z*cu>q5`=97VwaZ`lmOKPfY?PLLC!#S3XrXlj>JwvVkZ|AfzZ zZ((5Ab{-*y2$O=uqC~K9EI^VeEscTU({cs|-be-p;R_55T+v`TAc4be3P33tpu6&c zFr6WXA(cUa!IL46A(=rB$T9*(sv(0JFou0V@u{zVfK%Z4LV-yt>F?~g_Sm0lUV1U% zj>n|QSrv+tl>XQ3NxieJSLI~)+x=H-=Lg;1)+=-6VZ@#5@o!WaHe4vQe7$qV%lC&} zCG+5|LnHNXogrO3EpQqtNm34(mC0>tlZ&*!w-u(dECW#iUsR z>-5+|{sDb9;rCsSNh+SR-GGkY5I9}sWL8Dzsb2k-&0X!vk8W)#_?z8)!DD}20K+P^68^TM_o;mHy+%Y2gLxIJc^{>mr**~sPIgx#|3cFWxVCZ7L1 j{lCSH<_n*_pH2PNRCl{~tIoV;d5_wJZ>8SH#nc%9i%=of literal 0 HcmV?d00001 diff --git a/src/admin/assets/b6.webp b/src/admin/assets/b6.webp new file mode 100644 index 0000000000000000000000000000000000000000..68677d0e2c2787c7587f6c1710415c7c0f9e7353 GIT binary patch literal 996 zcmWIYbaT7I%)k)t>J$(bU=hK^z`$St#Oh$=>FgYEf)U7NU_1dLS%6Zxc_l?b?oJ93 zkx>fl4;UDM5DXHNiwhtutRzs?w`&l!mvynKz%R&|l3JV$RPq6cRf>R?1KlJ7WQ(Lj z*cu>q5`=97VwaZ`lmOKPfY?PLLC!#S3XrXlj>JwvVkZ|AfzZ zZ((5Ab{-*y2$O=uqC~K9EI^VeEscTU({cs|-be-p;R_55T+v`TAc4be3P33tpu6&c zFr6WXA(cUa!IL46A(=rB$T9*(sv(0JFou2JGBPmes~_MLFh8U)Nk!e*-_0~AaN@&j zY|YzxRaA}3|1`=qZBM-eUut{+0zt)BaGHIv7!UP0ZtW zoX@|+3&p?N`~R|o%YRXiNoR6S7T$a+sNy+E&SNqs)pepwC3@eDdB!@%(W%5eja zQyuajv*z4ZtzT;Kv@>;aAMdkJVQXEp zx0^zrUQ+XH+vKz@U}cC`<|kq9>i9Q0f=ez~Fjj0lwk>hN#$w5fPj)?FKH8EMb2w+l zoR}`=4ejijX-qjb{_(%Ue(jpJ;>?#KheqKEs=;zI4}9i!u(@;cvEkQaUsJq)JUUah zQM&ca+-uv)WR8Eld0Y9z+Lz{n9)DIz&Gr8A^ELnCUmnY*Y^Xf4DDI=CRu7+2=XS59 zK`SfvJFmW)Rr>Z^#LBbZzX@C}U{R3N3;ArXxGA%F#RR{$#%K4}Hv0U({>f(Xt~HzP iKaMit<*NIZ?A`t3Q=d}Rzh&Dm)<3^ktee5h!~g(5&_JvJ literal 0 HcmV?d00001 diff --git a/src/admin/assets/b7.webp b/src/admin/assets/b7.webp new file mode 100644 index 0000000000000000000000000000000000000000..c619d221ca15095a4fe9d527d5bff17cfa4040a8 GIT binary patch literal 1008 zcmWIYbaQ*b%)k)t>J$(bU=hK^z`$St#Oh$=>FgYEf)U7NU_1dLS%6Zxc_l?b?oJ93 zkx>fl4;UDM5DXHNiwhtutRzs?w`&l!mvynKz%R&|l3JV$RPq6cRf>R?1KlJ7WQ(Lj z*cu>q5`=97VwaZ`lmOKPfY?PLLC!#S3XrXlj>JwvVkZ|AfzZ zZ((5Ab{-*y2$O=uqC~K9EI^VeEscTU({cs|-be-p;R_55T+v`TAc4be3P33tpu6&c zFr6WXA(cUa!IL46A(=rB$T9*(sv(0JFou18GBPmes~_ML$bYn=SH-jI#H_lrN9E53 zL`wXAwdCQpUKRJG^PImw^?&mDdA~==b5hXe%LjSwMdp}&SkEN<;no6K^GQoi+?4Ru zlhLoy-zSu3@4wu9&9$FL8~-0Q^glcI|G(pjmStXB_DxK?@PGgA4GS|YAKo-FGW=2MyLLJ6xPE&bIdSk-Ntn=PbI`DDb4~fSyRBgMz0@r{`kTK%p=(p_3WDQj3cv zRMwhL_w?SRGsS7rwT!zrRg^ka0-16=CyAKn%$zy%X5P%3V&3eR|NplxS3i4Z);#53 z>o?@yKGre$-LB6ZlT%(% z*nva#RslPkcf)x@W-+~P~6hfa(Az5eXT^S-xN zChR}?;QQU?NkNi(i;1rmSkUu6oBJC80`oI~S`}Gp@c{k?$0_ z>7Q%)&XZSWxV9h5Gj+`ho!F-MY=ybj)mYt6Y|A7jMbDe`u4&V%^ut~LR&rkx_Vujy zS{fw2GF)q_zeI1k%=3S0pPmUAmwt#fxY@NxL#%VLMizVQR`IdgasQf18#cw)7Ta001ytMos_# literal 0 HcmV?d00001 diff --git a/src/admin/assets/b8.webp b/src/admin/assets/b8.webp new file mode 100644 index 0000000000000000000000000000000000000000..5ee95713e2f327a339d88f2cf963cd0379bdd3d7 GIT binary patch literal 964 zcmWIYbaUIo%)k)t>J$(bU=hK^z`$St#Oh$=>FgYEf)U7NU_1dLS%6Zxc_l?b?oJ93 zkx>fl4;UDM5DXHNiwhtutRzs?w`&l!mvynKz%R&|l3JV$RPq6cRf>R?1KlJ7WQ(Lj z*cu>q5`=97VwaZ`lmOKPfY?PLLC!#S3XrXlj>JwvVkZ|AfzZ zZ((5Ab{-*y2$O=uqC~K9EI^VeEscTU({cs|-be-p;R_55T+v`TAc4be3P33tpu6&c zFr6WXA(cUa!IL46A(=rB$T9*(sv(0JFou23F)}dds~_MLSiW#YuJX#6ikkwa*>|;W z_^)ju;W|lWX_lDc5A#M*l@E5@fzqBccN#a@voBQo!~AxC!m4NgS-)~OB(I*oC}iRE zV=p+kO~eAl8W^T*WSk`Tf{~GNq6$Nj%FR_;clJMer}v(5_s{3wQqJ#m5x7{-mUsIY z=cIS{rWDsJcurCQqc!o2TY!we)wv7|50bS)KCwJiZ&G<`*|)oyd4l2pgEGMAt*&bR zxNYf+zFC>l^(CvPwJb?on=7PLt;_yD?q#OzoxNtwk3;SCyw6pANqZm4ps-u`+b5|9 z*C(YquCmjNSmgLm`f&U6MS`r10ynFK71r0NM60Z4dOm04vL46gCz2IQ94}b(y*krm z@oC1k1&e-co%BVu^GjK#dhJ59TYo3(iXRP`;qiX`-MsC$ednKLh*7;{a=E9^{gZ)K zux6`PvqsmV5F>_vOkHm;fAiZnB}Y<9TGFSuL2~o4%Ic; literal 0 HcmV?d00001 diff --git a/src/admin/assets/b9.webp b/src/admin/assets/b9.webp new file mode 100644 index 0000000000000000000000000000000000000000..f70d3633cefd4b56adf6999657a95d9b988b359c GIT binary patch literal 1104 zcmWIYbaV4yVPFV%bqWXzu!!JdU|=u+VtX+1baoCn!3g9sFrEOBEI_H;ypp0IcPE92 z$S4K&2Mmlr2nLDC#RU)+RuZV{+ck*V%eq)q;1}dfNi9wWD)|7!Dn&rcfo>83vPIG% zYz+`Q3BtAkvCB&eN`PttK=2?(*InG6iuTNxN6ZXv`}7BDdI zw=ghlJC6`Ugh@eSQ6ktlU~DijrKK@2d|J-Hz#GZHAbf#=fh!s;2PAN~O#vt+19VqD z5T-NaFr+dlFnBWLF(fnS0a-@CNHt_I1IDmVBohOJzWo7y0rx`+la{#oZfmV~|B#q# zz;(l{?@gI>???Bo|2giMHM>nZ^v3Q%d=P8sA$ys_+j3=&msr%T-}t-mfZw_KPYuk% z58mOn()*l!^NF@(`SEl0Pp5k-H9t|R4>@^wNfXDfkg)wLiw?g!AN=-fGuOmP<{?MV znJk{6*|9|7Q@TN;=NqktO{+CT92z;aQu-G#B#3Y^I0_vPN+_d{g<bY?K6)#Z+hF`qZnqJ8ao3_kj zvHbP8`hHdY)UC=2;nKV78Ez#Tg)xG{(cyvi<5dpR;{}BcR%k@?&Aj~Nh~nu-9_>C| z&N^rR$Gj_*InnmIvMXj`()9-w_A@&w<{vHIYR0V=e0uqE7J;Ufr|G1X^3&rY&jRJ1JC`t+K+4`_Nw==cwyla;M!+q^E{XZ~k7icFmhW>uH}8 zcUyUvtlu12W~=RC?$4$F;-Kq_)kha>&3CEDTq_uz_|bgB6u;7Xm1Et?zprvw9$kFL zS7E{Ly}y)pmponA`X}V7Xt(KVxnCR&A!nW>nRafy(vajS#BLdxEVk7%fAzZ)kRf*H+`}5mVgs&roT6R c?)suF?P>kS-r)VeOVM_Q_N;GX`@L8g09XrQSO5S3 literal 0 HcmV?d00001 diff --git a/src/admin/assets/banner.webp b/src/admin/assets/banner.webp new file mode 100644 index 0000000000000000000000000000000000000000..5dc8c258eb0f3fbf38b48da503d418f2e60b452b GIT binary patch literal 18322 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 Zt-Js6i}CFFqrhyIyMONgZ+%+|006z~VfX+5 literal 0 HcmV?d00001 diff --git a/src/admin/assets/banner2.webp b/src/admin/assets/banner2.webp new file mode 100644 index 0000000000000000000000000000000000000000..638c8fe2228fbac15421f2d98a50e39f29f3127d GIT binary patch literal 13512 zcmeHtS6CE5*JckOjO5H9B12ZLsx=y|CIU0&`a&7zopd%}#rmgl|7asrs80Iwp z&reZWTCE3!xdcEx|8gA6EqjOeE*kPuP+h&ZP`p0?2y^@=nVP#g{g?E=%rUjjCjYDM z82A6O#{aqSzJ;YLMk(Mg=45ffJRGAFB}S&S{xA9QKiTZRWU+s;$9t#um^y0zWEV{h zX^d=vksn+CPqNwnB%3?A{Hq^|W0jYoQ zkGaMq$L|2}{1gDNqyM|iEExdmg8=|C^WSAG-vEFl2mtDa|GVtJ`^3rA#q>YT;b3ws zD=Ppv$_D@lQ%⪙W#^$&#qM5$iTG{TJv_Fh#WMa+^PcF9l9k@F| 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管理 +} diff --git a/src/admin/clients.tsx b/src/admin/clients.tsx index 93620b5..9541ab4 100644 --- a/src/admin/clients.tsx +++ b/src/admin/clients.tsx @@ -1,9 +1,19 @@ import { - IdCard, - MessageCircleMoreIcon, + Archive, + CalendarClock, + CircleDollarSignIcon, + ClipboardClock, + ClipboardList, + Clock1, + FolderCode, + LayoutDashboard, PanelLeftCloseIcon, PanelLeftOpenIcon, - UserRound, + ReceiptJapaneseYenIcon, + Router, + ShoppingBag, + TicketPercent, + UserCheck, } from "lucide-react" import { type ReactNode, useState } from "react" import { Link } from "react-router-dom" @@ -16,19 +26,20 @@ import { } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" import logoAvatar from "./assets/logo-avatar.svg" +import { useLayout } from "./layout-context" -export function Shell(props: { children: ReactNode; navbar: boolean }) { - const { navbar } = props +export function Shell(props: { children: ReactNode }) { + const { navbar } = useLayout() return (

{props.children} @@ -36,49 +47,45 @@ export function Shell(props: { children: ReactNode; navbar: boolean }) { ) } -export function Mask(props: { - navbar: boolean - setNavbar: (v: boolean) => void -}) { - const { navbar, setNavbar } = props +export function Content(props: { children: ReactNode }) { + const { navbar } = useLayout() + return ( +
+ {props.children} +
+ ) +} +export function Mask() { + const { navbar, setNavbar } = useLayout() return ( -
- - - - 实名认证 - - -
@@ -123,19 +110,11 @@ export function Header(props: { navbar: boolean; toggleNavbar: () => void }) { } function UserCenter() { - return
{"用户"}
+ return
用户
} -export function Navbar(props: { - navbar: boolean - setNavbar: (v: boolean) => void -}) { - const { navbar, setNavbar } = props - const closeNavBarIfMobile = () => { - if (window.innerWidth < 768) { - setNavbar(false) - } - } +export function Navbar() { + const { navbar } = useLayout() return ( ) } -function NavTitle(props: { label: string }) { - return ( -

- - {props.label} - - -

- ) -} - function NavItem(props: { href: string icon?: ReactNode @@ -212,6 +228,7 @@ function NavItem(props: { expand?: boolean }) { const [open, setOpen] = useState(false) + const { setNavbar } = useLayout() const handleOpenChange = (open: boolean) => { if (!props.expand) { @@ -219,6 +236,12 @@ function NavItem(props: { } } + const closeNavBarIfMobile = () => { + if (window.innerWidth < 768) { + setNavbar(false) + } + } + return ( @@ -230,6 +253,7 @@ function NavItem(props: { `group-data-[expand=true]:px-4`, )} to={props.href} + onClick={closeNavBarIfMobile} > {props.icon} diff --git a/src/admin/coupon/page.tsx b/src/admin/coupon/page.tsx new file mode 100644 index 0000000..919a30e --- /dev/null +++ b/src/admin/coupon/page.tsx @@ -0,0 +1,5 @@ +import Page from "@/components/page" + +export default function CouponPage() { + return 优惠券 +} diff --git a/src/admin/dashboard/page.tsx b/src/admin/dashboard/page.tsx index d117759..b0432dc 100644 --- a/src/admin/dashboard/page.tsx +++ b/src/admin/dashboard/page.tsx @@ -1,5 +1,415 @@ +import { useState } from "react" +import { EditInfoDialog } from "@/components/composites/dialogs/editInfoDialog" +import DataTable from "@/components/data-table" import Page from "@/components/page" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import b3 from "../assets/b3.webp" +import b5 from "../assets/b5.webp" +import b6 from "../assets/b6.webp" +import b7 from "../assets/b7.webp" +import b8 from "../assets/b8.webp" +import b9 from "../assets/b9.webp" +import b10 from "../assets/b10.webp" +import b11 from "../assets/b11.webp" +import b12 from "../assets/b12.webp" +import banner from "../assets/banner.webp" +import banner2 from "../assets/banner2.webp" export default function DashboardPage() { - return 账户总览页面 + const balanceData = { + balance: 68.6, + hCoin: 68.6, + } + + const accountInfo = { + phone: "191****1234", + qq: "123456123", + wechat: "123456123", + email: "123456123@qq.com", + taobao: "123456123", + } + + const recentOrders = [ + { + date: "2025.04.01", + product: "极光IP", + plan: "天卡(尊享)", + price: 3.0, + connections: 1, + account: "jx1234", + orderAmount: 3.0, + paidAmount: 3.0, + }, + { + date: "2025.04.01", + product: "极光IP", + plan: "天卡(尊享)", + price: 3.0, + connections: 1, + account: "jx1234", + orderAmount: 3.0, + paidAmount: 3.0, + }, + { + date: "2025.04.01", + product: "极光IP", + plan: "天卡(尊享)", + price: 3.0, + connections: 1, + account: "jx1234", + orderAmount: 3.0, + paidAmount: 3.0, + }, + { + date: "2025.04.01", + product: "极光IP", + plan: "天卡(尊享)", + price: 3.0, + connections: 1, + account: "jx1234", + orderAmount: 3.0, + paidAmount: 3.0, + }, + ] + + const consumptionData = { + today: 2365, + month: 312365, + todayRefund: 2365, + year: 256562, + monthRefund: 30, + todayRecharge: 50, + monthRecharge: 20, + } + + const ipAccountData = { + using: 30, + total: 50, + expired: 20, + yearConsumption: 2365, + } + const [editDialogOpen, setEditDialogOpen] = useState(false) + const handleSaveInfo = (data: any) => { + console.log("保存的信息:", data) + // 这里调用你的 API 保存数据 + } + return ( + + {/* banner */} +
+
+
+
+ + 可用余额(元) + +
+
+

+ {balanceData.balance} +

+
+ + +
+
+
+ banner +
+ +
+
+
+

可用H币(个)

+
+
+

+ {balanceData.hCoin} +

+ +
+
+ banner2 +
+
+ + {/* 最近订单 */} +
+

最近订单

+
+ row.original.price.toFixed(2), + }, + { accessorKey: "connections", header: "总连接数" }, + { accessorKey: "account", header: "账号" }, + { + accessorKey: "orderAmount", + header: "订单金额(元)", + cell: ({ row }) => row.original.orderAmount.toFixed(2), + }, + { + accessorKey: "paidAmount", + header: "实付金额(元)", + cell: ({ row }) => row.original.paidAmount.toFixed(2), + }, + ]} + /> +
+
+ + {/* 消费信息 */} +
+
+

消费信息

+
+
+
+ 今日消费 +
+

今日消费

+

+ {consumptionData.today} +

+
+
+
+ 当月消费 +
+

当月消费

+

+ {consumptionData.month} +

+
+
+
+ 今日退款 +
+

今日退款

+

+ {consumptionData.todayRefund} +

+
+
+
+ 本年消费 +
+

本年消费

+

+ {consumptionData.year} +

+
+
+
+ 今日充值 +
+

今日充值

+

+ {consumptionData.todayRecharge} +

+
+
+
+ 当月充值 +
+

当月充值

+

+ {consumptionData.monthRecharge} +

+
+
+
+ 当月退款 +
+

当月退款

+

+ {consumptionData.monthRefund} +

+
+
+
+
+ + {/* 账号信息 */} +
+

账号信息

+
+
+ {accountInfo.phone} + +
+ 账号二维码 +
+
+ +
+
+ 密码 + + ******** + +
+
+ QQ + + {accountInfo.qq || "未绑定"} + +
+
+ 微信号 + + {accountInfo.wechat || "未绑定"} + +
+
+ 淘宝会员 + + {accountInfo.taobao || "未绑定"} + +
+
+ 邮箱 + + {accountInfo.email || "未绑定"} + +
+
+
+
+ +
+
+ + {/* IP账号 */} +
+

IP账号

+
+
+
+ 使用中图标 +
+

{ipAccountData.using}

+

使用中

+
+
+
+ 总个数图标 +
+

{ipAccountData.total}

+

总个数

+
+
+
+ 已过期图标 +
+

{ipAccountData.expired}

+

已过期

+
+
+
+ +
+ ) } diff --git a/src/admin/effects.tsx b/src/admin/effects.tsx new file mode 100644 index 0000000..e515624 --- /dev/null +++ b/src/admin/effects.tsx @@ -0,0 +1,59 @@ +import { type ReactNode, useEffect } from "react" +import { useLayout } from "./layout-context" + +type Breakpoints = { + sm: boolean + md: boolean + lg: boolean + xl: boolean +} + +const breakpoints: Breakpoints = { sm: false, md: false, lg: false, xl: false } + +export function useBreakpoint() { + return breakpoints +} + +export const bp = breakpoints + +export default function Effects(props: { children?: ReactNode }) { + const { setNavbar } = useLayout() + + useEffect(() => { + const sm = window.matchMedia("(width >= 40rem)") + const md = window.matchMedia("(width >= 48rem)") + const lg = window.matchMedia("(width >= 64rem)") + const xl = window.matchMedia("(width >= 80rem)") + + breakpoints.sm = sm.matches + breakpoints.md = md.matches + breakpoints.lg = lg.matches + breakpoints.xl = xl.matches + + setNavbar(md.matches) + + sm.addEventListener("change", (e) => { + breakpoints.sm = e.matches + }) + md.addEventListener("change", (e) => { + breakpoints.md = e.matches + setNavbar(e.matches) + }) + lg.addEventListener("change", (e) => { + breakpoints.lg = e.matches + }) + xl.addEventListener("change", (e) => { + breakpoints.xl = e.matches + }) + + const html = document.documentElement + const originalFontSize = html.style.fontSize + html.style.fontSize = "16px" + + return () => { + html.style.fontSize = originalFontSize + } + }, [setNavbar]) + + return <>{props.children} +} diff --git a/src/admin/funds/page.tsx b/src/admin/funds/page.tsx new file mode 100644 index 0000000..63ca757 --- /dev/null +++ b/src/admin/funds/page.tsx @@ -0,0 +1,5 @@ +import Page from "@/components/page" + +export default function FundsPage() { + return 资金明细 +} diff --git a/src/admin/layout-context.tsx b/src/admin/layout-context.tsx new file mode 100644 index 0000000..15e0485 --- /dev/null +++ b/src/admin/layout-context.tsx @@ -0,0 +1,27 @@ +import { createContext, type ReactNode, useContext, useState } from "react" + +type LayoutState = { + navbar: boolean + setNavbar: (navbar: boolean) => void + toggleNavbar: () => void +} + +const LayoutContext = createContext(null) + +export function LayoutProvider(props: { children: ReactNode }) { + const [navbar, setNavbar] = useState(false) + + const toggleNavbar = () => setNavbar(!navbar) + + return ( + + {props.children} + + ) +} + +export function useLayout() { + const ctx = useContext(LayoutContext) + if (!ctx) throw new Error("useLayout must be used within LayoutProvider") + return ctx +} diff --git a/src/admin/layout.tsx b/src/admin/layout.tsx index d6623cb..92cf199 100644 --- a/src/admin/layout.tsx +++ b/src/admin/layout.tsx @@ -1,50 +1,50 @@ -import { useState } from "react" import { Outlet } from "react-router-dom" import { Content, Header, Mask, Navbar, Shell } from "./clients" +import Effects from "./effects" +import { LayoutProvider } from "./layout-context" export default function AdminLayout() { - const [navbar, setNavbar] = useState(true) - - const toggleNavbar = () => setNavbar(!navbar) return ( -
- -
- -
+ + +
+ +
+ +
-
-
-
+
+
+
- - - - - - - - - - - - + + + + + + + + + + + + - -
+ + - {/* 用 Outlet 替代 {props.children} */} - - - -
+ + + +
+ ) } diff --git a/src/admin/whitelist/page.tsx b/src/admin/whitelist/page.tsx new file mode 100644 index 0000000..db4be2c --- /dev/null +++ b/src/admin/whitelist/page.tsx @@ -0,0 +1,5 @@ +import Page from "@/components/page" + +export default function WhitelistPage() { + return 白名单 +} diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..e09c753 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,101 @@ +import type { ApiResponse } from "@/lib/api" +import { callByDevice, callByUser } from "./base" + +// 用户类型 +export interface User { + id: number + username: string + // ... +} + +export type LoginMode = "phone_code" | "password" + +export async function login(props: { + username: string + password: string + remember: boolean + mode: LoginMode +}): Promise { + // 使用 callByDevice 去拿 Token (设备级认证) + const result = await callByDevice<{ + access_token: string + refresh_token: string + expires_in: number + }>("/api/auth/token", { + ...props, + grant_type: "password", + login_type: props.mode, + }) + + if (!result.success) return result + + // 登录成功,存入 localStorage + const { access_token, refresh_token, expires_in } = result.data + localStorage.setItem("auth_token", access_token) + localStorage.setItem("auth_refresh", refresh_token) + localStorage.setItem( + "auth_expires_in", + String(Date.now() + expires_in * 1000), + ) + + return { success: true, data: undefined } +} + +export async function logout() { + const accessToken = localStorage.getItem("auth_token") + const refreshToken = localStorage.getItem("auth_refresh") + + if (accessToken && refreshToken) { + await callByUser("/api/auth/revoke", { + access_token: accessToken, + refresh_token: refreshToken, + }) + } + + // 清除前端本地缓存 + localStorage.removeItem("auth_token") + localStorage.removeItem("auth_refresh") + localStorage.removeItem("auth_expires_in") + + // 强制跳转到登录页 + window.location.href = "/login" +} + +// 获取当前用户信息 (校验 Token 是否有效) +export async function getProfile() { + return await callByUser("/api/auth/introspect") +} + +// 刷新 Token +export async function refreshAuth(): Promise<{ + access_token: string + refresh_token: string +} | null> { + const refreshToken = localStorage.getItem("auth_refresh") + if (!refreshToken) return null + + const resp = await callByDevice<{ + access_token: string + refresh_token: string + expires_in: number + }>(`/api/auth/token`, { + grant_type: "refresh_token", + refresh_token: refreshToken, + }) + + if (!resp.success) { + localStorage.removeItem("auth_refresh") + return null + } + + // 刷新成功,更新本地缓存 + const { access_token, refresh_token, expires_in } = resp.data + localStorage.setItem("auth_token", access_token) + localStorage.setItem("auth_refresh", refresh_token) + localStorage.setItem( + "auth_expires_in", + String(Date.now() + expires_in * 1000), + ) + + return { access_token, refresh_token } +} diff --git a/src/api/base.ts b/src/api/base.ts new file mode 100644 index 0000000..ccfc961 --- /dev/null +++ b/src/api/base.ts @@ -0,0 +1,158 @@ +import { + API_BASE_URL, + type ApiResponse, + CLIENT_ID, + CLIENT_SECRET, + UnauthorizedError, +} from "@/lib/api" + +let deviceToken: string | null = null +let deviceTokenExpire: Date | null = null + +async function call( + endpoint: string, + body?: string, + auth?: string, +): Promise> { + try { + const headers: HeadersInit = { + "Content-Type": "application/json", + } + if (auth) headers["Authorization"] = auth + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method: "POST", + headers, + body, + }) + + if (response.status === 401) { + // 如果是 刷新Token 的接口本身报 401,说明 RefreshToken 彻底失效,直接抛出错误 + if ( + endpoint.includes("/auth/token") || + endpoint.includes("/auth/revoke") + ) { + throw UnauthorizedError + } + + // 动态引入 auth.ts 里的刷新函数,避免循环引用报错 + const { refreshAuth } = await import("./auth") + + // 尝试刷新 Token + const newTokens = await refreshAuth() + + if (newTokens) { + // 刷新成功,用新 Token 重试当前请求(递归调用) + return call(endpoint, body, `Bearer ${newTokens.access_token}`) + } else { + // 刷新失败,抛出 401 错误,让上层处理跳转 + throw UnauthorizedError + } + } + + const contentType = response.headers.get("Content-Type") ?? "text/plain" + + if (contentType.includes("text/plain")) { + const text = await response.text() + if (!response.ok) { + return { + success: false, + status: response.status, + message: text || "请求失败", + } + } + return { success: true, data: undefined as T } + } + + if (contentType.includes("application/json")) { + const json = await response.json() + if (!response.ok) { + return { + success: false, + status: response.status, + message: json.message || json.error_description || "请求失败", + } + } + return { success: true, data: json } + } + + throw new Error(`无法解析响应数据: ${contentType}`) + } catch (e) { + console.error("后端请求异常:", e) + return { + success: false, + status: 500, + message: (e as Error).message || "网络错误", + } + } +} + +// ====================== +// Public +// ====================== +async function callPublic( + endpoint: string, + data?: unknown, +): Promise> { + return call(endpoint, data ? JSON.stringify(data) : undefined) +} + +// ====================== +// Device +// ====================== +async function callByDevice( + endpoint: string, + data?: unknown, +): Promise> { + // 检查内存中的 Token 是否过期 + if (!deviceToken || !deviceTokenExpire || new Date() >= deviceTokenExpire) { + // 用 btoa 生成 Basic 认证头 + const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`) + const resp = await call<{ + access_token: string + expires_in: number + }>( + "/api/auth/token", + JSON.stringify({ grant_type: "client_credentials" }), + `Basic ${basic}`, + ) + + if (!resp.success) { + return resp + } + deviceToken = resp.data.access_token + // 提前 60 秒过期,防止临界点失效 + deviceTokenExpire = new Date( + Date.now() + (resp.data.expires_in - 60) * 1000, + ) + } + + return call( + endpoint, + data ? JSON.stringify(data) : undefined, + `Bearer ${deviceToken}`, + ) +} + +// ====================== +// User +// ====================== +async function callByUser( + endpoint: string, + data?: unknown, +): Promise> { + // 这里我们假设用户登录后的 Token 存在 localStorage 里 + // 如果你是用 Cookie 存 Token,这里用 js-cookie 的 get 方法替换即可 + const token = localStorage.getItem("auth_token") + + if (!token) { + throw UnauthorizedError + } + return call( + endpoint, + data ? JSON.stringify(data) : undefined, + `Bearer ${token}`, + ) +} + +export { callByDevice, callByUser, callPublic } diff --git a/src/components/authGuard.tsx b/src/components/authGuard.tsx new file mode 100644 index 0000000..3ffde57 --- /dev/null +++ b/src/components/authGuard.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react" +import { Navigate, useLocation } from "react-router-dom" +import { getProfile, refreshAuth } from "@/api/auth" + +export default function AuthGuard({ children }: { children: React.ReactNode }) { + const location = useLocation() + const [isValid, setIsValid] = useState(null) + + useEffect(() => { + const checkAuth = async () => { + const accessToken = localStorage.getItem("auth_token") + const refreshToken = localStorage.getItem("auth_refresh") + + // 没 Token,判定无效 + if (!accessToken && !refreshToken) { + setIsValid(false) + return + } + + // 尝试用现有 AccessToken 获取用户信息 + const profileResp = await getProfile() + if (profileResp.success) { + setIsValid(true) + return + } + + // AccessToken 无效 尝试用 RefreshToken 刷新 + if (refreshToken) { + const newTokens = await refreshAuth() + if (newTokens) { + setIsValid(true) + return + } + } + + // 刷新失败或无 RefreshToken,为未登录 + localStorage.removeItem("auth_token") + localStorage.removeItem("auth_refresh") + localStorage.removeItem("auth_expires_in") + setIsValid(false) + } + + checkAuth() + }, []) + + if (isValid === null) + return ( +
+ 验证身份中... +
+ ) + + // if (!isValid) { + // return ( + // + // ) + // } + + return <>{children} +} diff --git a/src/components/composites/dialogs/editInfoDialog.tsx b/src/components/composites/dialogs/editInfoDialog.tsx new file mode 100644 index 0000000..4d4de77 --- /dev/null +++ b/src/components/composites/dialogs/editInfoDialog.tsx @@ -0,0 +1,149 @@ +import { Eye, EyeOff } from "lucide-react" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +interface EditInfoDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + initialData?: { + username: string + taobao: string + password: string + email: string + wechat: string + qq: string + } + onSave?: (data: any) => void +} + +export function EditInfoDialog({ + open, + onOpenChange, + initialData = { + username: "191****1234", + taobao: "191****1234", + password: "********", + email: "123456123@qq.com", + wechat: "", + qq: "", + }, + onSave, +}: EditInfoDialogProps) { + const [formData, setFormData] = useState(initialData) + const [showPassword, setShowPassword] = useState(false) + + const handleChange = (field: string, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })) + } + + const handleSubmit = () => { + onSave?.(formData) + onOpenChange(false) + } + + return ( + + + + + 编辑信息 + + + +
+ {/* 用户名 */} +
+ + handleChange("username", e.target.value)} + className="h-11 bg-gray-50 border-gray-200" + /> +
+ + {/* 淘宝会员名 */} +
+ + handleChange("taobao", e.target.value)} + className="h-11 bg-gray-50 border-gray-200" + /> +
+ + {/* 密码 */} +
+ +
+ handleChange("password", e.target.value)} + className="h-11 bg-gray-50 border-gray-200 pr-10" + /> + +
+
+ + {/* 邮箱 */} +
+ + handleChange("email", e.target.value)} + className="h-11 bg-gray-50 border-gray-200" + /> +
+ + {/* 微信 */} +
+ + handleChange("wechat", e.target.value)} + placeholder="请输入" + className="h-11 bg-gray-50 border-gray-200" + /> +
+ + {/* QQ */} +
+ + handleChange("qq", e.target.value)} + placeholder="请输入" + className="h-11 bg-gray-50 border-gray-200" + /> +
+ + {/* 保存按钮 */} + +
+
+
+ ) +} diff --git a/src/components/data-table.tsx b/src/components/data-table.tsx new file mode 100644 index 0000000..d62a085 --- /dev/null +++ b/src/components/data-table.tsx @@ -0,0 +1,123 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" +import { Loader } from "lucide-react" +import { Pagination, type PaginationProps } from "@/components/ui/pagination" +import { + TableBody, + TableCell, + TableHead, + TableHeader, + Table as TableRoot, + TableRow, +} from "@/components/ui/table" +import { cn } from "@/lib/utils" + +export type DataTableProps = { + data: T[] + status: "load" | "done" | "fail" + columns: ColumnDef[] + pagination?: PaginationProps + classNames?: { + headRow?: string + dataRow?: string + } +} + +export default function DataTable>( + props: DataTableProps, +) { + const table = useReactTable({ + data: props.data, + columns: props.columns, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + rowCount: props.pagination?.total ?? props.data.length, + state: { + pagination: { + pageIndex: props.pagination?.page ?? 0, + pageSize: props.pagination?.size ?? props.data.length, + }, + columnFilters: [], + }, + autoResetPageIndex: false, + }) + console.log("data length:", props.data.length) + console.log("pageSize:", props.pagination?.size ?? props.data.length) + console.log("rows:", table.getRowModel().rows.length) + return ( + <> + {/* 数据表 */} +
+ + + {table.getHeaderGroups().map(group => ( + + {group.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {props.status === "fail" ? ( + + + 加载失败 + + + ) : !props.data?.length ? ( + + + 暂无数据 + + + ) : ( + table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + )} + + + {props.status === "load" && ( +
+ + 加载中 +
+ )} +
+ + {/* 分页器 */} + {props.pagination && } + + ) +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index f59876f..1b57dc7 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,9 +1,8 @@ -import * as React from "react" -import { Dialog as DialogPrimitive } from "radix-ui" - -import { cn } from "@/lib/utils/index" -import { Button } from "@/components/ui/button" import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" +import type * as React from "react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils/index" function Dialog({ ...props @@ -38,7 +37,7 @@ function DialogOverlay({ data-slot="dialog-overlay" className={cn( "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", - className + className, )} {...props} /> @@ -60,7 +59,7 @@ function DialogContent({ data-slot="dialog-content" className={cn( "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", - className + className, )} {...props} > @@ -72,8 +71,7 @@ function DialogContent({ className="absolute top-2 right-2" size="icon-sm" > - + Close @@ -106,7 +104,7 @@ function DialogFooter({ data-slot="dialog-footer" className={cn( "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end", - className + className, )} {...props} > @@ -129,7 +127,7 @@ function DialogTitle({ data-slot="dialog-title" className={cn( "font-heading text-base leading-none font-medium", - className + className, )} {...props} /> @@ -145,7 +143,7 @@ function DialogDescription({ data-slot="dialog-description" className={cn( "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", - className + className, )} {...props} /> diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..71bc1c9 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import { Label as LabelPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils/index" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..bb596fb --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,304 @@ +"use client" + +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizontalIcon, +} from "lucide-react" +import type * as React from "react" +import { useEffect, useState } from "react" + +import { cn } from "@/lib/utils" + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./select" + +export interface PaginationProps { + page: number + size: number + total: number + sizeOptions?: number[] + onPageChange?: (page: number) => void + onSizeChange?: (size: number) => void + className?: string +} + +function Pagination({ + page, + size, + total, + sizeOptions = [10, 20, 50, 100], + onPageChange, + onSizeChange, + className, +}: PaginationProps) { + const [currentPage, setCurrentPage] = useState(page) + const totalPages = Math.ceil(total / size) + + // 同步外部 page 变化 + useEffect(() => { + setCurrentPage(page) + }, [page]) + + // 分页器逻辑 + const generatePaginationItems = () => { + // 最多显示7个页码,其余用省略号 + const SIBLINGS = 1 // 当前页左右各显示的页码数 + const DOTS = -1 // 省略号标记 + + if (totalPages <= 7) { + // 总页数少于7,全部显示 + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + + // 是否需要显示左边的省略号 + const showLeftDots = currentPage > 2 + SIBLINGS + + // 是否需要显示右边的省略号 + const showRightDots = currentPage < totalPages - (2 + SIBLINGS) + + if (showLeftDots && showRightDots) { + // 两边都有省略号 + const leftSiblingIndex = Math.max(currentPage - SIBLINGS, 1) + const rightSiblingIndex = Math.min(currentPage + SIBLINGS, totalPages) + + return [ + 1, + DOTS, + ...Array.from( + { length: rightSiblingIndex - leftSiblingIndex + 1 }, + (_, i) => leftSiblingIndex + i, + ), + DOTS, + totalPages, + ] + } + + if (!showLeftDots && showRightDots) { + // 只有右边有省略号 + return [ + ...Array.from({ length: 3 + SIBLINGS * 2 }, (_, i) => i + 1), + DOTS, + totalPages, + ] + } + + if (showLeftDots && !showRightDots) { + // 只有左边有省略号 + return [ + 1, + DOTS, + ...Array.from( + { length: 3 + SIBLINGS * 2 }, + (_, i) => totalPages - (3 + SIBLINGS * 2) + i + 1, + ), + ] + } + + return [] + } + + const handlePageChange = (newPage: number) => { + if (newPage < 1 || newPage > totalPages || newPage === currentPage) { + return + } + setCurrentPage(newPage) + onPageChange?.(newPage) + } + + const handlePageSizeChange = (newSize: string) => { + const parsedSize = parseInt(newSize, 10) + if (onSizeChange) { + onSizeChange(parsedSize) + } + } + + const paginationItems = generatePaginationItems() + + return ( +
+
+ 共 {total} 条记录,每页 + + 条 +
+ + + + + handlePageChange(currentPage - 1)} + className={ + currentPage === 1 ? "opacity-50 pointer-events-none" : "" + } + /> + + + {paginationItems.map((pageNum, index) => { + if (pageNum === -1) { + return ( + + + + ) + } + + return ( + + handlePageChange(pageNum)} + > + {pageNum} + + + ) + })} + + + handlePageChange(currentPage + 1)} + className={ + currentPage === totalPages + ? "opacity-50 pointer-events-none" + : "" + } + /> + + + +
+ ) +} + +function PaginationLayout({ + className, + ...props +}: React.ComponentProps<"nav">) { + return ( +