From 3a9a32e2c227364c338725fba74d593c16fe29ee Mon Sep 17 00:00:00 2001 From: miwei Date: Wed, 4 Mar 2026 17:06:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 /admin/dashboard 页面,提供充值订单统计与分析: - 汇总统计卡片(今日/累计充值金额、订单数、成功率、平均充值) - 每日充值趋势折线图(recharts,支持 7/30/90 天切换) - 充值排行榜(Top 10 用户) - 支付方式分布(水平条形图) - 与 /admin 订单管理页面互相导航 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 + pnpm-lock.yaml | 308 ++++++++++++++++++++ src/app/admin/dashboard/page.tsx | 155 ++++++++++ src/app/admin/page.tsx | 28 +- src/app/api/admin/dashboard/route.ts | 139 +++++++++ src/components/admin/DailyChart.tsx | 110 +++++++ src/components/admin/DashboardStats.tsx | 58 ++++ src/components/admin/Leaderboard.tsx | 86 ++++++ src/components/admin/PaymentMethodChart.tsx | 61 ++++ 9 files changed, 937 insertions(+), 10 deletions(-) create mode 100644 src/app/admin/dashboard/page.tsx create mode 100644 src/app/api/admin/dashboard/route.ts create mode 100644 src/components/admin/DailyChart.tsx create mode 100644 src/components/admin/DashboardStats.tsx create mode 100644 src/components/admin/Leaderboard.tsx create mode 100644 src/components/admin/PaymentMethodChart.tsx diff --git a/package.json b/package.json index 34efc4a..28d1b35 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "sub2apipay", "version": "0.1.0", "private": true, + "packageManager": "pnpm@10.30.3", "scripts": { "dev": "next dev", "build": "next build", @@ -22,6 +23,7 @@ "qrcode": "^1.5.4", "react": "19.2.3", "react-dom": "19.2.3", + "recharts": "^3.7.0", "stripe": "^20.4.0", "zod": "^4.3.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db97f81..3ccbe71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + recharts: + specifier: ^3.7.0 + version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1) stripe: specifier: ^20.4.0 version: 20.4.0(@types/node@20.19.35) @@ -730,6 +733,17 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -877,6 +891,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stripe/stripe-js@8.9.0': resolution: {integrity: sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==} engines: {node: '>=12.16'} @@ -994,6 +1011,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1023,6 +1067,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1393,6 +1440,10 @@ packages: cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1420,6 +1471,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -1456,6 +1551,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1555,6 +1653,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.45.0: + resolution: {integrity: sha512-RArCX+Zea16+R1jg4mH223Z8p/ivbJjIkU3oC6ld2bdUfmDxiCkFYSi9zLOR2anucWJUeH4Djnzgd0im0nD3dw==} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -1691,6 +1792,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1895,6 +1999,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1907,6 +2017,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2499,6 +2613,18 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -2511,6 +2637,22 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + recharts@3.7.0: + resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2532,6 +2674,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2749,6 +2894,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2831,6 +2979,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + valibot@1.2.0: resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} peerDependencies: @@ -2839,6 +2992,9 @@ packages: typescript: optional: true + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3539,6 +3695,18 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.3 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -3620,6 +3788,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@stripe/stripe-js@8.9.0': {} '@swc/helpers@0.5.15': @@ -3726,6 +3896,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -3756,6 +3950,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/use-sync-external-store@0.0.6': {} + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4162,6 +4358,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 6.2.0 + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4184,6 +4382,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: @@ -4214,6 +4450,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} + deep-is@0.1.4: {} deepmerge-ts@7.1.5: {} @@ -4373,6 +4611,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.45.0: {} + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -4615,6 +4855,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} exsolve@1.0.8: {} @@ -4809,6 +5051,10 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.4: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -4822,6 +5068,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -5384,12 +5632,47 @@ snapshots: react-is@16.13.1: {} + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-refresh@0.18.0: {} react@19.2.3: {} readdirp@4.1.2: {} + recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-is: 16.13.1 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.3) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -5418,6 +5701,8 @@ snapshots: require-main-filename@2.0.0: {} + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -5703,6 +5988,8 @@ snapshots: tapable@2.3.0: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -5824,10 +6111,31 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + valibot@1.2.0(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@7.3.1(@types/node@20.19.35)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.27.3 diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..af40d44 --- /dev/null +++ b/src/app/admin/dashboard/page.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { useState, useEffect, useCallback, Suspense } from 'react'; +import PayPageLayout from '@/components/PayPageLayout'; +import DashboardStats from '@/components/admin/DashboardStats'; +import DailyChart from '@/components/admin/DailyChart'; +import Leaderboard from '@/components/admin/Leaderboard'; +import PaymentMethodChart from '@/components/admin/PaymentMethodChart'; + +interface DashboardData { + summary: { + today: { amount: number; orderCount: number; paidCount: number }; + total: { amount: number; orderCount: number; paidCount: number }; + successRate: number; + avgAmount: number; + }; + dailySeries: { date: string; amount: number; count: number }[]; + leaderboard: { userId: number; userName: string | null; userEmail: string | null; totalAmount: number; orderCount: number }[]; + paymentMethods: { paymentType: string; amount: number; count: number; percentage: number }[]; + meta: { days: number; generatedAt: string }; +} + +const DAYS_OPTIONS = [7, 30, 90] as const; + +function DashboardContent() { + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light'; + const uiMode = searchParams.get('ui_mode') || 'standalone'; + const isDark = theme === 'dark'; + const isEmbedded = uiMode === 'embedded'; + + const [days, setDays] = useState(30); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const fetchData = useCallback(async () => { + if (!token) return; + setLoading(true); + setError(''); + try { + const res = await fetch(`/api/admin/dashboard?token=${encodeURIComponent(token)}&days=${days}`); + if (!res.ok) { + if (res.status === 401) { + setError('管理员凭证无效'); + return; + } + throw new Error('请求失败'); + } + setData(await res.json()); + } catch { + setError('加载数据失败'); + } finally { + setLoading(false); + } + }, [token, days]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + if (!token) { + return ( +
+
+

缺少管理员凭证

+

请从 Sub2API 平台正确访问管理页面

+
+
+ ); + } + + const navParams = new URLSearchParams(); + navParams.set('token', token); + if (theme === 'dark') navParams.set('theme', 'dark'); + if (isEmbedded) navParams.set('ui_mode', 'embedded'); + + const btnBase = [ + 'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors', + isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100', + ].join(' '); + + const btnActive = [ + 'inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-medium', + isDark ? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40' : 'bg-blue-600 text-white', + ].join(' '); + + return ( + + {DAYS_OPTIONS.map((d) => ( + + ))} + + 订单管理 + + + + } + > + {error && ( +
+ {error} + +
+ )} + + {loading ? ( +
加载中...
+ ) : data ? ( +
+ + +
+ + +
+
+ ) : null} +
+ ); +} + +export default function DashboardPage() { + return ( + +
加载中...
+ + } + > + +
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index f4689f6..0cab2aa 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -162,6 +162,16 @@ function AdminContent() { REFUNDED: '已退款', }; + const navParams = new URLSearchParams(); + if (token) navParams.set('token', token); + if (isDark) navParams.set('theme', 'dark'); + if (isEmbedded) navParams.set('ui_mode', 'embedded'); + + const btnBase = [ + 'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors', + isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100', + ].join(' '); + return ( - 刷新 - + <> + + 数据概览 + + + } > {error && ( diff --git a/src/app/api/admin/dashboard/route.ts b/src/app/api/admin/dashboard/route.ts new file mode 100644 index 0000000..ee4be15 --- /dev/null +++ b/src/app/api/admin/dashboard/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; +import { OrderStatus } from '@prisma/client'; + +/** 格式化 Date 为 YYYY-MM-DD(使用本地时区,与 PostgreSQL DATE() 一致) */ +function toDateStr(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +export async function GET(request: NextRequest) { + if (!(await verifyAdminToken(request))) return unauthorizedResponse(); + + const searchParams = request.nextUrl.searchParams; + const days = Math.min(365, Math.max(1, Number(searchParams.get('days') || '30'))); + + const now = new Date(); + const startDate = new Date(now); + startDate.setDate(startDate.getDate() - days); + startDate.setHours(0, 0, 0, 0); + + const todayStart = new Date(now); + todayStart.setHours(0, 0, 0, 0); + + const paidStatuses: OrderStatus[] = [ + OrderStatus.PAID, + OrderStatus.RECHARGING, + OrderStatus.COMPLETED, + OrderStatus.REFUNDING, + OrderStatus.REFUNDED, + OrderStatus.REFUND_FAILED, + ]; + + const [todayStats, totalStats, todayOrders, totalOrders, dailyRaw, leaderboardRaw, paymentMethodStats] = + await Promise.all([ + // Today paid aggregate + prisma.order.aggregate({ + where: { status: { in: paidStatuses }, paidAt: { gte: todayStart } }, + _sum: { amount: true }, + _count: { _all: true }, + }), + // Total paid aggregate + prisma.order.aggregate({ + where: { status: { in: paidStatuses } }, + _sum: { amount: true }, + _count: { _all: true }, + }), + // Today total orders + prisma.order.count({ where: { createdAt: { gte: todayStart } } }), + // Total orders + prisma.order.count(), + // Daily series (raw query for DATE truncation) + prisma.$queryRaw<{ date: string; amount: string; count: bigint }[]>` + SELECT DATE(paid_at) as date, SUM(amount)::text as amount, COUNT(*) as count + FROM orders + WHERE status IN ('PAID', 'RECHARGING', 'COMPLETED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED') + AND paid_at >= ${startDate} + GROUP BY DATE(paid_at) + ORDER BY date + `, + // Leaderboard: GROUP BY user_id only, MAX() for name/email to avoid splitting rows on name changes + prisma.$queryRaw< + { user_id: number; user_name: string | null; user_email: string | null; total_amount: string; order_count: bigint }[] + >` + SELECT user_id, MAX(user_name) as user_name, MAX(user_email) as user_email, + SUM(amount)::text as total_amount, COUNT(*) as order_count + FROM orders + WHERE status IN ('PAID', 'RECHARGING', 'COMPLETED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED') + AND paid_at >= ${startDate} + GROUP BY user_id + ORDER BY SUM(amount) DESC + LIMIT 10 + `, + // Payment method distribution (within time range) + prisma.order.groupBy({ + by: ['paymentType'], + where: { status: { in: paidStatuses }, paidAt: { gte: startDate } }, + _sum: { amount: true }, + _count: { _all: true }, + }), + ]); + + // Fill missing dates for continuous line chart (use local timezone consistently) + const dailyMap = new Map(); + for (const row of dailyRaw) { + const dateStr = typeof row.date === 'string' ? row.date : toDateStr(new Date(row.date)); + dailyMap.set(dateStr, { amount: Number(row.amount), count: Number(row.count) }); + } + + const dailySeries: { date: string; amount: number; count: number }[] = []; + const cursor = new Date(startDate); + while (cursor <= now) { + const dateStr = toDateStr(cursor); + const entry = dailyMap.get(dateStr); + dailySeries.push({ date: dateStr, amount: entry?.amount ?? 0, count: entry?.count ?? 0 }); + cursor.setDate(cursor.getDate() + 1); + } + + // Calculate summary + const todayPaidAmount = Number(todayStats._sum?.amount || 0); + const todayPaidCount = todayStats._count._all; + const totalPaidAmount = Number(totalStats._sum?.amount || 0); + const totalPaidCount = totalStats._count._all; + const successRate = totalOrders > 0 ? (totalPaidCount / totalOrders) * 100 : 0; + const avgAmount = totalPaidCount > 0 ? totalPaidAmount / totalPaidCount : 0; + + // Payment method total for percentage calc + const paymentTotal = paymentMethodStats.reduce((sum, m) => sum + Number(m._sum?.amount || 0), 0); + + return NextResponse.json({ + summary: { + today: { amount: todayPaidAmount, orderCount: todayOrders, paidCount: todayPaidCount }, + total: { amount: totalPaidAmount, orderCount: totalOrders, paidCount: totalPaidCount }, + successRate: Math.round(successRate * 10) / 10, + avgAmount: Math.round(avgAmount * 100) / 100, + }, + dailySeries, + leaderboard: leaderboardRaw.map((row) => ({ + userId: row.user_id, + userName: row.user_name, + userEmail: row.user_email, + totalAmount: Number(row.total_amount), + orderCount: Number(row.order_count), + })), + paymentMethods: paymentMethodStats.map((m) => { + const amount = Number(m._sum?.amount || 0); + return { + paymentType: m.paymentType, + amount, + count: m._count._all, + percentage: paymentTotal > 0 ? Math.round((amount / paymentTotal) * 1000) / 10 : 0, + }; + }), + meta: { days, generatedAt: now.toISOString() }, + }); +} diff --git a/src/components/admin/DailyChart.tsx b/src/components/admin/DailyChart.tsx new file mode 100644 index 0000000..be078a2 --- /dev/null +++ b/src/components/admin/DailyChart.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'; + +interface DailyData { + date: string; + amount: number; + count: number; +} + +interface DailyChartProps { + data: DailyData[]; + dark?: boolean; +} + +function formatDate(dateStr: string) { + const [, m, d] = dateStr.split('-'); + return `${m}/${d}`; +} + +function formatAmount(value: number) { + if (value >= 10000) return `¥${(value / 10000).toFixed(1)}w`; + if (value >= 1000) return `¥${(value / 1000).toFixed(1)}k`; + return `¥${value}`; +} + +interface TooltipPayload { + value: number; + dataKey: string; +} + +function CustomTooltip({ + active, + payload, + label, + dark, +}: { + active?: boolean; + payload?: TooltipPayload[]; + label?: string; + dark?: boolean; +}) { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+ {payload.map((p) => ( +

+ {p.dataKey === 'amount' ? '金额' : '笔数'}: {p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value} +

+ ))} +
+ ); +} + +export default function DailyChart({ data, dark }: DailyChartProps) { + // Auto-calculate tick interval: show ~10-15 labels max + const tickInterval = data.length > 30 ? Math.ceil(data.length / 12) - 1 : 0; + if (data.length === 0) { + return ( +
+

每日充值趋势

+

暂无数据

+
+ ); + } + + const axisColor = dark ? '#64748b' : '#94a3b8'; + const gridColor = dark ? '#334155' : '#e2e8f0'; + + return ( +
+

每日充值趋势

+ + + + + + } /> + + + +
+ ); +} diff --git a/src/components/admin/DashboardStats.tsx b/src/components/admin/DashboardStats.tsx new file mode 100644 index 0000000..e25b886 --- /dev/null +++ b/src/components/admin/DashboardStats.tsx @@ -0,0 +1,58 @@ +'use client'; + +interface Summary { + today: { amount: number; orderCount: number; paidCount: number }; + total: { amount: number; orderCount: number; paidCount: number }; + successRate: number; + avgAmount: number; +} + +interface DashboardStatsProps { + summary: Summary; + dark?: boolean; +} + +export default function DashboardStats({ summary, dark }: DashboardStatsProps) { + const cards = [ + { label: '今日充值', value: `¥${summary.today.amount.toLocaleString()}`, accent: true }, + { label: '今日订单', value: `${summary.today.paidCount}/${summary.today.orderCount}` }, + { label: '累计充值', value: `¥${summary.total.amount.toLocaleString()}`, accent: true }, + { label: '累计订单', value: String(summary.total.paidCount) }, + { label: '成功率', value: `${summary.successRate}%` }, + { label: '平均充值', value: `¥${summary.avgAmount.toFixed(2)}` }, + ]; + + return ( +
+ {cards.map((card) => ( +
+

+ {card.label} +

+

+ {card.value} +

+
+ ))} +
+ ); +} diff --git a/src/components/admin/Leaderboard.tsx b/src/components/admin/Leaderboard.tsx new file mode 100644 index 0000000..f70c7b8 --- /dev/null +++ b/src/components/admin/Leaderboard.tsx @@ -0,0 +1,86 @@ +'use client'; + +interface LeaderboardEntry { + userId: number; + userName: string | null; + userEmail: string | null; + totalAmount: number; + orderCount: number; +} + +interface LeaderboardProps { + data: LeaderboardEntry[]; + dark?: boolean; +} + +const RANK_STYLES: Record = { + 1: { light: 'bg-amber-100 text-amber-700', dark: 'bg-amber-500/20 text-amber-300' }, + 2: { light: 'bg-slate-200 text-slate-600', dark: 'bg-slate-500/20 text-slate-300' }, + 3: { light: 'bg-orange-100 text-orange-700', dark: 'bg-orange-500/20 text-orange-300' }, +}; + +export default function Leaderboard({ data, dark }: LeaderboardProps) { + const thCls = `px-4 py-3 text-left text-xs font-medium uppercase ${dark ? 'text-slate-400' : 'text-gray-500'}`; + const tdCls = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-300' : 'text-slate-700'}`; + const tdMuted = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-400' : 'text-gray-500'}`; + + if (data.length === 0) { + return ( +
+

充值排行榜 (Top 10)

+

暂无数据

+
+ ); + } + + return ( +
+

+ 充值排行榜 (Top 10) +

+
+ + + + + + + + + + + {data.map((entry, i) => { + const rank = i + 1; + const rankStyle = RANK_STYLES[rank]; + return ( + + + + + + + ); + })} + +
#用户累计金额订单数
+ {rankStyle ? ( + + {rank} + + ) : ( + {rank} + )} + +
{entry.userName || `#${entry.userId}`}
+ {entry.userEmail && ( +
+ {entry.userEmail} +
+ )} +
+ ¥{entry.totalAmount.toLocaleString()} + {entry.orderCount}
+
+
+ ); +} diff --git a/src/components/admin/PaymentMethodChart.tsx b/src/components/admin/PaymentMethodChart.tsx new file mode 100644 index 0000000..05be717 --- /dev/null +++ b/src/components/admin/PaymentMethodChart.tsx @@ -0,0 +1,61 @@ +'use client'; + +interface PaymentMethod { + paymentType: string; + amount: number; + count: number; + percentage: number; +} + +interface PaymentMethodChartProps { + data: PaymentMethod[]; + dark?: boolean; +} + +const TYPE_CONFIG: Record = { + alipay: { label: '支付宝', light: 'bg-blue-500', dark: 'bg-blue-400' }, + wechat: { label: '微信支付', light: 'bg-green-500', dark: 'bg-green-400' }, + stripe: { label: 'Stripe', light: 'bg-purple-500', dark: 'bg-purple-400' }, +}; + +export default function PaymentMethodChart({ data, dark }: PaymentMethodChartProps) { + if (data.length === 0) { + return ( +
+

支付方式分布

+

暂无数据

+
+ ); + } + + return ( +
+

支付方式分布

+
+ {data.map((method) => { + const config = TYPE_CONFIG[method.paymentType] || { + label: method.paymentType, + light: 'bg-gray-500', + dark: 'bg-gray-400', + }; + return ( +
+
+ {config.label} + + ¥{method.amount.toLocaleString()} · {method.percentage}% + +
+
+
+
+
+ ); + })} +
+
+ ); +}