Merge pull request #317 from awesomeYG/feat-dashboard-time-filter

feat: add timerange filter
This commit is contained in:
Yoko
2025-08-29 14:35:43 +08:00
committed by GitHub
27 changed files with 2571 additions and 269 deletions

21
ui/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@@ -20,12 +20,18 @@
"@mui/lab": "6.0.0-beta.19",
"@mui/material": "^6.4.12",
"@yokowu/modelkit-ui": "0.7.1",
"@tailwindcss/vite": "^4.1.12",
"ahooks": "^3.8.4",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"dayjs": "^1.11.7",
"decimal.js": "^10.5.0",
"echarts": "^5.6.0",
"lottie-react": "^2.4.1",
"lucide-react": "^0.542.0",
"monaco-editor": "^0.52.2",
"react": "^19.1.0",
"react-activity-calendar": "^2.7.12",
@@ -39,6 +45,8 @@
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12",
"unist-util-visit": "^5.0.0",
"vite-plugin-dts": "^4.5.4",
"zod": "^4.0.17"
@@ -57,6 +65,7 @@
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"shiki": "^3.7.0",
"tw-animate-css": "^1.3.7",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"

445
ui/pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
'@mui/material':
specifier: ^6.4.12
version: 6.5.0(@emotion/react@11.14.0(@types/react@19.1.10)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.10)(react@19.1.1))(@types/react@19.1.10)(react@19.1.1))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@tailwindcss/vite':
specifier: ^4.1.12
version: 4.1.12(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1))
'@yokowu/modelkit-ui':
specifier: 0.7.1
version: 0.7.1(8525073519bd78ecc27b6ab63a7efdab)
@@ -41,6 +44,18 @@ importers:
axios:
specifier: ^1.9.0
version: 1.11.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.1.0
version: 4.1.0
date-fns-tz:
specifier: ^3.2.0
version: 3.2.0(date-fns@4.1.0)
dayjs:
specifier: ^1.11.7
version: 1.11.13
@@ -53,6 +68,9 @@ importers:
lottie-react:
specifier: ^2.4.1
version: 2.4.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@19.1.1)
monaco-editor:
specifier: ^0.52.2
version: 0.52.2
@@ -92,12 +110,18 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
tailwindcss:
specifier: ^4.1.12
version: 4.1.12
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
vite-plugin-dts:
specifier: ^4.5.4
version: 4.5.4(@types/node@24.2.1)(rollup@4.46.2)(typescript@5.8.3)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1))
version: 4.5.4(@types/node@24.2.1)(rollup@4.46.2)(typescript@5.8.3)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1))
zod:
specifier: ^4.0.17
version: 4.0.17
@@ -122,7 +146,7 @@ importers:
version: 15.5.13
'@vitejs/plugin-react':
specifier: ^4.4.1
version: 4.7.0(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1))
version: 4.7.0(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1))
dotenv:
specifier: ^17.2.1
version: 17.2.1
@@ -141,6 +165,9 @@ importers:
shiki:
specifier: ^3.7.0
version: 3.9.2
tw-animate-css:
specifier: ^1.3.7
version: 1.3.7
typescript:
specifier: ~5.8.3
version: 5.8.3
@@ -149,7 +176,7 @@ importers:
version: 8.39.1(eslint@9.33.0(jiti@2.5.1))(typescript@5.8.3)
vite:
specifier: ^6.3.5
version: 6.3.5(@types/node@24.2.1)(jiti@2.5.1)
version: 6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)
packages:
@@ -576,9 +603,16 @@ packages:
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
engines: {node: 20 || >=22}
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@@ -938,6 +972,100 @@ packages:
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@tailwindcss/node@4.1.12':
resolution: {integrity: sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==}
'@tailwindcss/oxide-android-arm64@4.1.12':
resolution: {integrity: sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.1.12':
resolution: {integrity: sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.1.12':
resolution: {integrity: sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.1.12':
resolution: {integrity: sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.12':
resolution: {integrity: sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.12':
resolution: {integrity: sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.12':
resolution: {integrity: sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.12':
resolution: {integrity: sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.12':
resolution: {integrity: sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.12':
resolution: {integrity: sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.1.12':
resolution: {integrity: sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.1.12':
resolution: {integrity: sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.1.12':
resolution: {integrity: sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==}
engines: {node: '>= 10'}
'@tailwindcss/vite@4.1.12':
resolution: {integrity: sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@types/argparse@1.0.38':
resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==}
@@ -1258,9 +1386,16 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
citty@0.1.6:
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -1343,6 +1478,11 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
date-fns-tz@3.2.0:
resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==}
peerDependencies:
date-fns: ^3.0.0 || ^4.0.0
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
@@ -1384,6 +1524,10 @@ packages:
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-libc@2.0.4:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -1407,6 +1551,10 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -1861,6 +2009,74 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.30.1:
resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.30.1:
resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.30.1:
resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.30.1:
resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.30.1:
resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.30.1:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -1907,6 +2123,11 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
lucide-react@0.542.0:
resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
@@ -2076,6 +2297,19 @@ packages:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@3.0.2:
resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
engines: {node: '>= 18'}
mkdirp@3.0.1:
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
engines: {node: '>=10'}
hasBin: true
mlly@1.7.4:
resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==}
@@ -2550,6 +2784,20 @@ packages:
resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==}
hasBin: true
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
tailwindcss@4.1.12:
resolution: {integrity: sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==}
tapable@2.2.3:
resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==}
engines: {node: '>=6'}
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
tinyexec@1.0.1:
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
@@ -2585,6 +2833,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tw-animate-css@1.3.7:
resolution: {integrity: sha512-lvLb3hTIpB5oGsk8JmLoAjeCHV58nKa2zHYn8yWOoG5JJusH3bhJlF2DLAZ/5NmJ+jyH3ssiAx/2KmbhavJy/A==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -2745,6 +2996,10 @@ packages:
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
@@ -3172,11 +3427,20 @@ snapshots:
dependencies:
'@isaacs/balanced-match': 4.0.1
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.2
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.30
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.30
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
@@ -3519,6 +3783,77 @@ snapshots:
'@standard-schema/utils@0.3.0': {}
'@tailwindcss/node@4.1.12':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.18.3
jiti: 2.5.1
lightningcss: 1.30.1
magic-string: 0.30.17
source-map-js: 1.2.1
tailwindcss: 4.1.12
'@tailwindcss/oxide-android-arm64@4.1.12':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.1.12':
optional: true
'@tailwindcss/oxide-darwin-x64@4.1.12':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.1.12':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.12':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.1.12':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.1.12':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.1.12':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.1.12':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.1.12':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.1.12':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.1.12':
optional: true
'@tailwindcss/oxide@4.1.12':
dependencies:
detect-libc: 2.0.4
tar: 7.4.3
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.12
'@tailwindcss/oxide-darwin-arm64': 4.1.12
'@tailwindcss/oxide-darwin-x64': 4.1.12
'@tailwindcss/oxide-freebsd-x64': 4.1.12
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.12
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.12
'@tailwindcss/oxide-linux-arm64-musl': 4.1.12
'@tailwindcss/oxide-linux-x64-gnu': 4.1.12
'@tailwindcss/oxide-linux-x64-musl': 4.1.12
'@tailwindcss/oxide-wasm32-wasi': 4.1.12
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.12
'@tailwindcss/oxide-win32-x64-msvc': 4.1.12
'@tailwindcss/vite@4.1.12(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1))':
dependencies:
'@tailwindcss/node': 4.1.12
'@tailwindcss/oxide': 4.1.12
tailwindcss: 4.1.12
vite: 6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)
'@types/argparse@1.0.38': {}
'@types/babel__core@7.20.5':
@@ -3697,7 +4032,7 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.7.0(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1))':
'@vitejs/plugin-react@4.7.0(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1))':
dependencies:
'@babel/core': 7.28.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0)
@@ -3705,7 +4040,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 6.3.5(@types/node@24.2.1)(jiti@2.5.1)
vite: 6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)
transitivePeerDependencies:
- supports-color
@@ -3930,10 +4265,16 @@ snapshots:
dependencies:
readdirp: 4.1.2
chownr@3.0.0: {}
citty@0.1.6:
dependencies:
consola: 3.4.2
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -4008,6 +4349,10 @@ snapshots:
csstype@3.1.3: {}
date-fns-tz@3.2.0(date-fns@4.1.0):
dependencies:
date-fns: 4.1.0
date-fns@4.1.0: {}
dayjs@1.11.13: {}
@@ -4034,6 +4379,8 @@ snapshots:
destr@2.0.5: {}
detect-libc@2.0.4: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
@@ -4060,6 +4407,11 @@ snapshots:
emoji-regex@8.0.0: {}
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.3
entities@4.5.0: {}
entities@6.0.1: {}
@@ -4564,6 +4916,51 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lightningcss-darwin-arm64@1.30.1:
optional: true
lightningcss-darwin-x64@1.30.1:
optional: true
lightningcss-freebsd-x64@1.30.1:
optional: true
lightningcss-linux-arm-gnueabihf@1.30.1:
optional: true
lightningcss-linux-arm64-gnu@1.30.1:
optional: true
lightningcss-linux-arm64-musl@1.30.1:
optional: true
lightningcss-linux-x64-gnu@1.30.1:
optional: true
lightningcss-linux-x64-musl@1.30.1:
optional: true
lightningcss-win32-arm64-msvc@1.30.1:
optional: true
lightningcss-win32-x64-msvc@1.30.1:
optional: true
lightningcss@1.30.1:
dependencies:
detect-libc: 2.0.4
optionalDependencies:
lightningcss-darwin-arm64: 1.30.1
lightningcss-darwin-x64: 1.30.1
lightningcss-freebsd-x64: 1.30.1
lightningcss-linux-arm-gnueabihf: 1.30.1
lightningcss-linux-arm64-gnu: 1.30.1
lightningcss-linux-arm64-musl: 1.30.1
lightningcss-linux-x64-gnu: 1.30.1
lightningcss-linux-x64-musl: 1.30.1
lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1
lines-and-columns@1.2.4: {}
local-pkg@1.1.1:
@@ -4609,6 +5006,10 @@ snapshots:
dependencies:
yallist: 4.0.0
lucide-react@0.542.0(react@19.1.1):
dependencies:
react: 19.1.1
magic-string@0.30.17:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -4991,6 +5392,14 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
minipass@7.1.2: {}
minizlib@3.0.2:
dependencies:
minipass: 7.1.2
mkdirp@3.0.1: {}
mlly@1.7.4:
dependencies:
acorn: 8.15.0
@@ -5551,6 +5960,21 @@ snapshots:
transitivePeerDependencies:
- encoding
tailwind-merge@3.3.1: {}
tailwindcss@4.1.12: {}
tapable@2.2.3: {}
tar@7.4.3:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.2
minizlib: 3.0.2
mkdirp: 3.0.1
yallist: 5.0.0
tinyexec@1.0.1: {}
tinyglobby@0.2.14:
@@ -5578,6 +6002,8 @@ snapshots:
tslib@2.8.1: {}
tw-animate-css@1.3.7: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -5663,7 +6089,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-plugin-dts@4.5.4(@types/node@24.2.1)(rollup@4.46.2)(typescript@5.8.3)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)):
vite-plugin-dts@4.5.4(@types/node@24.2.1)(rollup@4.46.2)(typescript@5.8.3)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)):
dependencies:
'@microsoft/api-extractor': 7.52.10(@types/node@24.2.1)
'@rollup/pluginutils': 5.2.0(rollup@4.46.2)
@@ -5676,13 +6102,13 @@ snapshots:
magic-string: 0.30.17
typescript: 5.8.3
optionalDependencies:
vite: 6.3.5(@types/node@24.2.1)(jiti@2.5.1)
vite: 6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)
transitivePeerDependencies:
- '@types/node'
- rollup
- supports-color
vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1):
vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1):
dependencies:
esbuild: 0.25.9
fdir: 6.4.6(picomatch@4.0.3)
@@ -5694,6 +6120,7 @@ snapshots:
'@types/node': 24.2.1
fsevents: 2.3.3
jiti: 2.5.1
lightningcss: 1.30.1
vscode-uri@3.1.0: {}
@@ -5726,6 +6153,8 @@ snapshots:
yallist@4.0.0: {}
yallist@5.0.0: {}
yaml@1.10.2: {}
yargs-parser@21.1.1: {}

View File

@@ -1594,69 +1594,53 @@ export interface V1CliCreateParams {
}
export interface GetCategoryStatDashboardParams {
/**
* 持续时间 (小时或天数)`
* @min 24
* @max 90
* @default 90
*/
/** 持续时间 (小时或天数)` */
duration?: number;
/**
* 精度: "hour", "day"
* @default "day"
*/
precision: "hour" | "day";
/** 结束时间, 时间范围优先级高于精度选择 */
end_at?: number;
/** 精度: "hour", "day" */
precision?: string;
/** 开始时间, 时间范围优先级高于精度选择 */
start_at?: number;
/** 用户ID可选参数 */
user_id?: string;
}
export interface GetTimeStatDashboardParams {
/**
* 持续时间 (小时或天数)`
* @min 24
* @max 90
* @default 90
*/
/** 持续时间 (小时或天数)` */
duration?: number;
/**
* 精度: "hour", "day"
* @default "day"
*/
precision: "hour" | "day";
/** 结束时间, 时间范围优先级高于精度选择 */
end_at?: number;
/** 精度: "hour", "day" */
precision?: string;
/** 开始时间, 时间范围优先级高于精度选择 */
start_at?: number;
/** 用户ID可选参数 */
user_id?: string;
}
export interface GetUserCodeRankDashboardParams {
/**
* 持续时间 (小时或天数)`
* @min 24
* @max 90
* @default 90
*/
/** 持续时间 (小时或天数)` */
duration?: number;
/**
* 精度: "hour", "day"
* @default "day"
*/
precision: "hour" | "day";
/** 结束时间, 时间范围优先级高于精度选择 */
end_at?: number;
/** 精度: "hour", "day" */
precision?: string;
/** 开始时间, 时间范围优先级高于精度选择 */
start_at?: number;
/** 用户ID可选参数 */
user_id?: string;
}
export interface GetUserEventsDashboardParams {
/**
* 持续时间 (小时或天数)`
* @min 24
* @max 90
* @default 90
*/
/** 持续时间 (小时或天数)` */
duration?: number;
/**
* 精度: "hour", "day"
* @default "day"
*/
precision: "hour" | "day";
/** 结束时间, 时间范围优先级高于精度选择 */
end_at?: number;
/** 精度: "hour", "day" */
precision?: string;
/** 开始时间, 时间范围优先级高于精度选择 */
start_at?: number;
/** 用户ID可选参数 */
user_id?: string;
}
@@ -1667,18 +1651,14 @@ export interface GetUserHeatmapDashboardParams {
}
export interface GetUserStatDashboardParams {
/**
* 持续时间 (小时或天数)`
* @min 24
* @max 90
* @default 90
*/
/** 持续时间 (小时或天数)` */
duration?: number;
/**
* 精度: "hour", "day"
* @default "day"
*/
precision: "hour" | "day";
/** 结束时间, 时间范围优先级高于精度选择 */
end_at?: number;
/** 精度: "hour", "day" */
precision?: string;
/** 开始时间, 时间范围优先级高于精度选择 */
start_at?: number;
/** 用户ID可选参数 */
user_id?: string;
}
@@ -1880,35 +1860,27 @@ export interface GetUserListCompletionRecordParams {
}
export interface GetUserDashboardEventsParams {
/**
* 持续时间 (小时或天数)`
* @min 24
* @max 90
* @default 90
*/
/** 持续时间 (小时或天数)` */
duration?: number;
/**
* 精度: "hour", "day"
* @default "day"
*/
precision: "hour" | "day";
/** 结束时间, 时间范围优先级高于精度选择 */
end_at?: number;
/** 精度: "hour", "day" */
precision?: string;
/** 开始时间, 时间范围优先级高于精度选择 */
start_at?: number;
/** 用户ID可选参数 */
user_id?: string;
}
export interface GetUserDashboardStatParams {
/**
* 持续时间 (小时或天数)`
* @min 24
* @max 90
* @default 90
*/
/** 持续时间 (小时或天数)` */
duration?: number;
/**
* 精度: "hour", "day"
* @default "day"
*/
precision: "hour" | "day";
/** 结束时间, 时间范围优先级高于精度选择 */
end_at?: number;
/** 精度: "hour", "day" */
precision?: string;
/** 开始时间, 时间范围优先级高于精度选择 */
start_at?: number;
/** 用户ID可选参数 */
user_id?: string;
}

View File

@@ -0,0 +1,117 @@
import React from "react";
import { Spinner } from "@/components/ui/spinner-1";
import clsx from "clsx";
const sizes = [
{
tiny: "px-1.5 h-6 text-sm",
small: "px-1.5 h-8 text-sm",
medium: "px-2.5 h-10 text-sm",
large: "px-3.5 h-12 text-base"
},
{
tiny: "w-6 h-6 text-sm",
small: "w-8 h-8 text-sm",
medium: "w-10 h-10 text-sm",
large: "w-12 h-12 text-base"
}
];
const types = {
primary: "bg-gray-1000 hover:bg-gray-1000-h text-background-100 fill-background-100",
secondary: "bg-background-100 hover:bg-gray-alpha-200 text-gray-1000 fill-gray-1000 border border-gray-alpha-400",
tertiary: "bg-none hover:bg-gray-alpha-200 text-gray-1000 fill-gray-1000",
error: "bg-red-800 hover:bg-red-900 text-white fill-white",
warning: "bg-amber-800 hover:bg-amber-850 text-black fill-black"
};
const shapes = {
square: {
tiny: "rounded",
small: "rounded-md",
medium: "rounded-md",
large: "rounded-lg"
},
circle: {
tiny: "rounded-[100%]",
small: "rounded-[100%]",
medium: "rounded-[100%]",
large: "rounded-[100%]"
},
rounded: {
tiny: "rounded-[100px]",
small: "rounded-[100px]",
medium: "rounded-[100px]",
large: "rounded-[100px]"
}
};
export interface ButtonProps {
size?: keyof typeof sizes[0];
type?: keyof typeof types;
variant?: "styled" | "unstyled";
shape?: keyof typeof shapes;
svgOnly?: boolean;
children?: React.ReactNode;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
shadow?: boolean;
loading?: boolean;
disabled?: boolean;
fullWidth?: boolean;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
ref?: React.Ref<HTMLButtonElement>;
className?: string;
}
export const Button = ({
size = "medium",
type = "primary",
variant = "styled",
shape = "square",
svgOnly = false,
children,
prefix,
suffix,
shadow = false,
loading = false,
disabled = false,
fullWidth = false,
onClick,
ref,
className,
...rest
}: ButtonProps) => {
return (
<button
ref={ref}
type="submit"
disabled={disabled}
onClick={onClick}
tabIndex={0}
className={clsx(
"flex justify-center items-center gap-0.5 duration-150",
sizes[+svgOnly][size],
(disabled || loading) ? "bg-gray-100 text-gray-700 border border-gray-400 cursor-not-allowed" : types[type],
shapes[shape][size],
shadow && "shadow-border-small border-none",
fullWidth && "w-full",
variant === "unstyled" ? "outline-none px-0 h-fit bg-transparent hover:bg-transparent text-gray-1000" : "focus:shadow-focus-ring focus:outline-0",
className
)}
{...rest}
>
{loading
? <Spinner size={size === "large" ? 24 : 16} />
: prefix
}
<span className={clsx(
"relative overflow-hidden whitespace-nowrap overflow-ellipsis font-sans",
size !== "tiny" && variant !== "unstyled" && "px-1.5"
)}>
{children}
</span>
{!loading && suffix}
</button>
);
};

View File

@@ -0,0 +1,870 @@
import {
addDays,
addMonths, endOfDay,
endOfMonth,
endOfWeek, format, isEqual,
isSameDay,
isSameMonth, isToday,
isValid,
isWithinInterval, parse, startOfDay,
startOfMonth,
startOfWeek, sub,
subDays, subHours, subMinutes,
subMonths,
subWeeks, subYears
} from "date-fns";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button-1";
import { Material } from "@/components/ui/material-1";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select-1";
import { formatInTimeZone, fromZonedTime } from "date-fns-tz";
import { useClickOutside } from "@/components/ui/use-click-outside";
import clsx from "clsx";
import { enUS } from "date-fns/locale";
import { twMerge } from "tailwind-merge";
export type SecondTimeRange = {
start_at: number;
end_at: number;
}
const ClockIcon = () => (
<svg height="16" strokeLinejoin="round" viewBox="0 0 16 16" width="16">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.5 8C14.5 11.5899 11.5899 14.5 8 14.5C4.41015 14.5 1.5 11.5899 1.5 8C1.5 4.41015 4.41015 1.5 8 1.5C11.5899 1.5 14.5 4.41015 14.5 8ZM16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8ZM8.75 4.75V4H7.25V4.75V7.875C7.25 8.18976 7.39819 8.48615 7.65 8.675L9.55 10.1L10.15 10.55L11.05 9.35L10.45 8.9L8.75 7.625V4.75Z"
className="fill-gray-1000"
/>
</svg>
);
const ArrowBottomIcon = ({ className }: { className?: string }) => (
<svg
height="16"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="16"
className={clsx("fill-gray-1000", className)}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.0607 5.49999L13.5303 6.03032L8.7071 10.8535C8.31658 11.2441 7.68341 11.2441 7.29289 10.8535L2.46966 6.03032L1.93933 5.49999L2.99999 4.43933L3.53032 4.96966L7.99999 9.43933L12.4697 4.96966L13 4.43933L14.0607 5.49999Z"
/>
</svg>
);
const ArrowLeftIcon = () => (
<svg
height="16"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="16"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.5 14.0607L9.96966 13.5303L5.14644 8.7071C4.75592 8.31658 4.75592 7.68341 5.14644 7.29289L9.96966 2.46966L10.5 1.93933L11.5607 2.99999L11.0303 3.53032L6.56065 7.99999L11.0303 12.4697L11.5607 13L10.5 14.0607Z"
className="fill-gray-700"
/>
</svg>
);
const ArrowRightIcon = () => (
<svg
height="16"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="16"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.50001 1.93933L6.03034 2.46966L10.8536 7.29288C11.2441 7.68341 11.2441 8.31657 10.8536 8.7071L6.03034 13.5303L5.50001 14.0607L4.43935 13L4.96968 12.4697L9.43935 7.99999L4.96968 3.53032L4.43935 2.99999L5.50001 1.93933Z"
className="fill-gray-700"
/>
</svg>
);
const CalendarIcon = () => (
<svg
height="16"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="16"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.5 0.5V1.25V2H10.5V1.25V0.5H12V1.25V2H14H15.5V3.5V13.5C15.5 14.8807 14.3807 16 13 16H3C1.61929 16 0.5 14.8807 0.5 13.5V3.5V2H2H4V1.25V0.5H5.5ZM2 3.5H14V6H2V3.5ZM2 7.5V13.5C2 14.0523 2.44772 14.5 3 14.5H13C13.5523 14.5 14 14.0523 14 13.5V7.5H2Z"
/>
</svg>
);
const ClearIcon = () => (
<svg
height="16"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="16"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.4697 13.5303L13 14.0607L14.0607 13L13.5303 12.4697L9.06065 7.99999L13.5303 3.53032L14.0607 2.99999L13 1.93933L12.4697 2.46966L7.99999 6.93933L3.53032 2.46966L2.99999 1.93933L1.93933 2.99999L2.46966 3.53032L6.93933 7.99999L2.46966 12.4697L1.93933 13L2.99999 14.0607L3.53032 13.5303L7.99999 9.06065L12.4697 13.5303Z"
/>
</svg>
);
const parseRelativeDate = (input: string) => {
const regex = /(\d+)\s*(day|week|month|year|hour)s?/i;
const match = input.match(regex);
if (!match) {
return null;
}
const value = parseInt(match[1]);
const unit = match[2].toLowerCase() + "s";
const now = new Date();
const start = startOfDay(sub(now, { [unit]: value }));
const end = endOfDay(now);
return {
[input]: { text: input, start, end }
};
};
const parseFixedRange = (input: string) => {
const rangePattern = /(.+)\s*[-]\s*(.+)/;
const match = input.match(rangePattern);
if (!match) {
return parseExactDate(input);
}
const [, startStr, endStr] = match;
if (!startStr || !endStr) {
return null;
}
const possibleFormats = ["d MMM yyyy", "d MMM", "yyyy-MM-dd"];
for (const format of possibleFormats) {
const now = new Date();
const year = now.getFullYear();
const start = parse(startStr, format, now, { locale: enUS });
const end = parse(endStr, format, now, { locale: enUS });
const finalStart = isValid(start) ? startOfDay(start) : null;
const finalEnd = isValid(end) ? endOfDay(end) : null;
if (finalStart && finalEnd) {
if (format === "d MMM") {
finalStart.setFullYear(year);
finalEnd.setFullYear(year);
}
return {
[input]: { text: input, start: finalStart, end: finalEnd }
};
}
}
return null;
};
const parseExactDate = (input: string) => {
const now = new Date();
const currentYear = now.getFullYear();
const dateFormats = ["d MMM yyyy", "d MMM", "yyyy-MM-dd"];
for (const format of dateFormats) {
const date = parse(input.trim(), format, now, { locale: enUS });
if (isValid(date)) {
if (format === "d MMM") {
date.setFullYear(currentYear);
}
return {
[input]: {
text: input,
start: startOfDay(date),
end: endOfDay(date)
}
};
}
}
return null;
};
const parseDateInput = (input: string) => {
const relative = parseRelativeDate(input);
if (relative) return relative;
const fixedRange = parseFixedRange(input);
if (fixedRange) return fixedRange;
const exact = parseExactDate(input);
if (exact) return exact;
return null;
};
const filterPresets = (obj: Record<string, any>, search: string) => {
if (!search) {
return obj;
}
const searchWords = search.toLowerCase().split("-").filter(Boolean);
const filtered = Object.fromEntries(
Object.entries(obj).filter(([_, value]) => {
const keyLower = value.text.toLowerCase();
return searchWords.every(word => keyLower.includes(word));
})
);
if (Object.entries(filtered).length > 0) {
return filtered;
}
const parsed = parseDateInput(search);
if (parsed) {
return parsed;
}
const numberMatch = search.match(/\d+/);
if (!numberMatch) {
return {};
}
const n = parseInt(numberMatch[0], 10);
const now = new Date();
return {
[`last-${n}-days`]: {
text: `Last ${n} Days`,
start: startOfDay(subDays(now, n)),
end: endOfDay(now)
},
[`last-${n}-weeks`]: {
text: `Last ${n} Weeks`,
start: startOfDay(subWeeks(now, n)),
end: endOfDay(now)
},
[`last-${n}-months`]: {
text: `Last ${n} Months`,
start: startOfDay(subMonths(now, n)),
end: endOfDay(now)
},
[`last-${n}-years`]: {
text: `Last ${n} Years`,
start: startOfDay(subYears(now, n)),
end: endOfDay(now)
}
};
};
const formatDateRange = (start: Date, end: Date, timezone: string) => {
const isStartMidnight = isEqual(start, startOfDay(start));
const isEndEOD = isEqual(end, endOfDay(end));
const sameDay = isSameDay(start, end);
const formatSingle = (date: Date) =>
formatInTimeZone(
date,
timezone,
isStartMidnight ? "EEE, MMM d" : "EEE, MMM d, HH:mm"
);
const formatMonth = (date: Date) => formatInTimeZone(date, timezone, "MMM");
const formatDay = (date: Date) => formatInTimeZone(date, timezone, "d");
const formatYear = (date: Date) => formatInTimeZone(date, timezone, "yy");
const formatDateWithTimeIfNeeded = (date: Date, showTime: boolean) =>
formatInTimeZone(date, timezone, showTime ? "MMM d, HH:mm" : "MMM d");
if (sameDay) {
return formatSingle(start);
}
const sameMonth = formatMonth(start) === formatMonth(end) && formatYear(start) === formatYear(end);
const sameYear = formatYear(start) === formatYear(end);
const startHasTime = !isStartMidnight;
const endHasTime = !isEndEOD;
if (startHasTime || endHasTime) {
const startFormatted = formatDateWithTimeIfNeeded(start, startHasTime);
const endFormatted = formatDateWithTimeIfNeeded(end, endHasTime);
return `${startFormatted} - ${endFormatted}`;
}
if (sameMonth) {
return `${formatMonth(start)} ${formatDay(start)} - ${formatDay(end)}`;
}
if (sameYear) {
return `${formatMonth(start)} ${formatDay(start)} - ${formatMonth(end)} ${formatDay(end)}`;
}
return `${formatMonth(start)} ${formatDay(start)} '${formatYear(start)} - ${formatMonth(end)} ${formatDay(end)} '${formatYear(end)}`;
};
const typeRelativeTimes = [
{
text: "45分钟",
start: subMinutes(new Date(), 45),
end: new Date()
},
{
text: "12 小时",
start: subHours(new Date(), 12),
end: new Date()
},
{
text: "10 天",
start: startOfDay(subDays(new Date(), 10)),
end: endOfDay(new Date())
},
{
text: "2 周",
start: startOfDay(subWeeks(new Date(), 2)),
end: endOfDay(new Date())
},
{
text: "1 月",
start: startOfDay(subMonths(new Date(), 1)),
end: endOfDay(new Date())
},
{
text: "昨天",
start: startOfDay(subDays(new Date(), 1)),
end: endOfDay(subDays(new Date(), 1))
},
{
text: "今天",
start: startOfDay(new Date()),
end: endOfDay(new Date())
}
];
interface CalendarComboboxProps {
stacked: boolean;
compact: boolean;
disabled: boolean;
value: RangeValue | null;
onChange: (date: RangeValue | null) => void;
presets: {
[key: string]: {
text: string;
start: Date;
end: Date;
};
};
presetIndex?: number;
}
const CalendarCombobox = ({
stacked,
compact,
value,
onChange,
presets,
presetIndex,
disabled
}: CalendarComboboxProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [inputValue, setInputValue] = useState<string>("");
const [currentPreset, setCurrentPreset] = useState<any | null>(null);
const ref = useRef<HTMLDivElement>(null);
const onFocus = () => {
setIsOpen(true);
};
const onChangeInputValue = (value: string) => {
setInputValue(value);
};
const onClick = (value: any) => {
setInputValue(value.text);
setCurrentPreset(value);
onChange({ start: value.start, end: value.end });
setIsOpen(false);
};
const filteredPresets = filterPresets(presets, inputValue);
useClickOutside(ref, () => setIsOpen(false));
useEffect(() => {
const array = Object.entries(presets);
if (presetIndex !== undefined && presetIndex >= 0 && presetIndex < array.length) {
setInputValue(array[presetIndex][1].text);
setCurrentPreset(array[presetIndex][1]);
onChange({ start: array[presetIndex][1].start, end: array[presetIndex][1].end });
}
}, [presetIndex]);
useEffect(() => {
if (currentPreset) {
if (currentPreset.start !== value?.start || currentPreset.end !== value?.end) {
setCurrentPreset(null);
setInputValue("");
}
}
}, [value]);
return (
<div
ref={ref}
className={twMerge(clsx(
"inline-block text-sm font-sans",
compact ? "w-[180px] absolute left-[38px]" : "w-[250px] relative",
compact && !isOpen && "pl-[140px]",
compact && (isOpen || (currentPreset && currentPreset?.start === value?.start && currentPreset?.end === value?.end)) && "pl-0"
))}
>
<Input
prefix={compact ? undefined : <ClockIcon />}
prefixStyling={"pl-2.5"}
suffix={<ArrowBottomIcon className={clsx("duration-200", isOpen && "rotate-180")} />}
suffixStyling={clsx(
"cursor-pointer",
compact && !isOpen && (!currentPreset || (currentPreset?.start !== value?.start && currentPreset?.end !== value?.end)) && "w-10 !px-0"
)}
placeholder="选择时间段"
disabled={disabled}
onFocus={onFocus}
value={inputValue}
onChange={onChangeInputValue}
wrapperClassName={clsx(
"hover:z-10",
stacked && !compact && "rounded-b-none",
!stacked && !compact && "rounded-r-none",
compact && "rounded-l-none",
(isOpen || (compact && currentPreset && currentPreset?.start === value?.start && currentPreset?.end === value?.end)) && "z-10"
)}
className={clsx(
"pl-2 placeholder:!text-gray-1000 placeholder:!opacity-100",
compact && !isOpen && (!currentPreset || (currentPreset?.start !== value?.start && currentPreset?.end !== value?.end)) && "!w-0 !px-0"
)}
/>
<Material
type="menu"
className={clsx(
"absolute z-50 top-12 left-0",
compact ? "w-full" : "grid grid-cols-2 w-[200%]",
isOpen && "opacity-100",
!isOpen && "opacity-0 pointer-events-none duration-200"
)}
>
<ul className="p-2 border-r border-r-gray-200">
{Object.entries(filteredPresets).length > 0 ? Object.entries(filteredPresets).map(([key, value]) => (
<li
key={key}
className="flex items-center cursor-pointer px-2 w-full h-9 rounded-md hover:bg-gray-alpha-300 active:bg-gray-alpha-300 font-sans text-sm text-gray-1000"
onClick={() => onClick(value)}
>
{value.text}
</li>
)) : (
<li
className="flex items-center cursor-pointer px-2 w-full h-9 rounded-md hover:bg-gray-alpha-300 active:bg-gray-alpha-300 font-sans text-sm text-gray-1000">
{inputValue}
</li>
)}
</ul>
{!compact && (
<div className="p-4 pr-[30px]">
<div className="font-sans text-gray-900 text-sm"></div>
<div className="mt-2 flex flex-wrap gap-1">
{typeRelativeTimes.map((value) => (
<button
key={value.text}
className="font-mono text-[13px] text-gray-1000 px-1.5 h-5 inline-flex items-center bg-accents-2 border-none rounded cursor-pointer"
onClick={() => onClick(value)}
>
{value.text}
</button>
))}
</div>
{/* <div className="font-sans text-gray-900 text-sm mt-4">选择固定时间</div> */}
{/* <div className="mt-2 flex flex-wrap gap-1">
{typeFixedTimes.map((value) => (
<button
key={value.text}
className="font-mono text-[13px] text-gray-1000 px-1.5 h-5 inline-flex items-center bg-accents-2 border-none rounded cursor-pointer"
>
{value.text}
</button>
))}
</div> */}
</div>
)}
</Material>
</div>
);
};
export interface RangeValue {
start: Date | null;
end: Date | null;
}
interface CalendarProps {
allowClear?: boolean;
compact?: boolean;
isDocsPage?: boolean;
stacked?: boolean;
disabled: boolean;
horizontalLayout?: boolean;
showTimeInput?: boolean;
popoverAlignment?: "start" | "center" | "end";
value: RangeValue | null;
onChange: (date: RangeValue | null) => void;
presets?: {
[key: string]: {
text: string;
start: Date;
end: Date;
};
};
presetIndex?: number;
minValue?: Date;
maxValue?: Date;
}
export const Calendar = ({
allowClear = false,
compact = false,
isDocsPage = false,
stacked = false,
horizontalLayout = false,
showTimeInput = true,
popoverAlignment = "start",
value,
disabled = false,
onChange,
presets,
presetIndex,
minValue,
maxValue,
}: CalendarProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [currentDate, setCurrentDate] = useState<Date>(new Date());
const [hoverDate, setHoverDate] = useState<Date | null>(null);
const [isSelecting, setIsSelecting] = useState<boolean>(false);
const timezones = useMemo(() => ([
{
value: "UTC",
label: "UTC"
},
{
value: Intl.DateTimeFormat().resolvedOptions().timeZone,
label: `Local (${Intl.DateTimeFormat().resolvedOptions().timeZone})`
}
]), []);
const [selectedTimezone, setSelectedTimezone] = useState(timezones[1].value);
const [startDate, setStartDate] = useState<string>(formatInTimeZone(value?.start || new Date(), selectedTimezone, "MMM dd, yyyy"));
const [startTime, setStartTime] = useState<string>(formatInTimeZone(startOfDay(value?.start || new Date()), selectedTimezone, "HH:mm"));
const [endDate, setEndDate] = useState<string>(formatInTimeZone(value?.end || new Date(), selectedTimezone, "MMM dd, yyyy"));
const [endTime, setEndTime] = useState<string>(formatInTimeZone(endOfDay(value?.end || new Date()), selectedTimezone, "HH:mm"));
const [startDateError, setStartDateError] = useState<boolean>(false);
const [startTimeError, setStartTimeError] = useState<boolean>(false);
const [endDateError, setEndDateError] = useState<boolean>(false);
const [endTimeError, setEndTimeError] = useState<boolean>(false);
const calendarRef = useRef<HTMLDivElement | null>(null);
useClickOutside(calendarRef, () => setIsOpen(false));
useEffect(() => {
window.addEventListener("resize", () => setIsOpen(false));
window.addEventListener("scroll", () => setIsOpen(false));
return () => {
window.removeEventListener("resize", () => setIsOpen(false));
window.removeEventListener("scroll", () => setIsOpen(false));
};
}, []);
const prevMonth = () => setCurrentDate(subMonths(currentDate, 1));
const nextMonth = () => setCurrentDate(addMonths(currentDate, 1));
const daysArray = [];
let day = startOfWeek(startOfMonth(currentDate), { weekStartsOn: 1 });
while (day <= endOfWeek(endOfMonth(currentDate), { weekStartsOn: 1 })) {
daysArray.push(day);
day = addDays(day, 1);
}
const handleDateClick = (day: Date) => {
if (!value?.start || (value.start && value.end)) {
onChange({ start: startOfDay(day), end: null });
setHoverDate(day);
setIsSelecting(true);
} else if (isSelecting) {
if (day > value.start) {
onChange({ ...value, end: endOfDay(day) });
} else {
onChange({ start: startOfDay(day), end: endOfDay(value.start) });
}
setIsSelecting(false);
setHoverDate(null);
setIsOpen(false);
}
};
const handleMouseEnter = (day: Date) => {
if (value?.start && !value.end) {
setHoverDate(day);
}
};
const onApply = () => {
const parsedStartDate = parse(startDate, "MMM dd, yyyy", new Date());
const parsedStartTime = parse(startTime || "", "HH:mm", new Date());
const parsedEndDate = parse(endDate, "MMM dd, yyyy", new Date());
const parsedEndTime = parse(endTime || "", "HH:mm", new Date());
if (
parsedStartDate.toString() === "Invalid Date" ||
parsedStartTime.toString() === "Invalid Date" ||
parsedEndDate.toString() === "Invalid Date" ||
parsedEndTime.toString() === "Invalid Date"
) {
setStartDateError(parsedStartDate.toString() === "Invalid Date");
setStartTimeError(parsedStartTime.toString() === "Invalid Date");
setEndDateError(parsedEndDate.toString() === "Invalid Date");
setEndTimeError(parsedEndTime.toString() === "Invalid Date");
} else {
setStartDateError(false);
setStartTimeError(false);
setEndDateError(false);
setEndTimeError(false);
const parsedStart = parse(`${startDate} ${startTime}`, "MMM d, yyyy HH:mm", new Date());
const parsedEnd = parse(`${endDate} ${endTime}`, "MMM d, yyyy HH:mm", new Date());
onChange({
start: fromZonedTime(parsedStart, selectedTimezone),
end: fromZonedTime(parsedEnd, selectedTimezone)
});
}
};
useEffect(() => {
setStartDate(formatInTimeZone(value?.start || new Date(), selectedTimezone, "MMM dd, yyyy"));
setStartTime(formatInTimeZone(value?.start || startOfDay(new Date()), selectedTimezone, "HH:mm"));
setEndDate(formatInTimeZone(value?.end || new Date(), selectedTimezone, "MMM dd, yyyy"));
setEndTime(formatInTimeZone(value?.end || endOfDay(new Date()), selectedTimezone, "HH:mm"));
}, [isOpen, value]);
return (
<div className="relative">
<div className={clsx(
presets && "flex",
presets && stacked && "flex-col",
compact && "w-[220px]"
)}>
{presets && (
<div>
<CalendarCombobox
stacked={stacked}
compact={compact}
disabled={disabled}
presets={presets}
value={value}
onChange={onChange}
presetIndex={presetIndex}
/>
</div>
)}
<div className="flex justify-between items-center">
<div className="relative">
<Button
disabled={disabled}
className={clsx(
"!justify-start focus:!border-transparent focus:!shadow-focus-input",
presets && !stacked && !compact && "rounded-l-none -ml-[1px]",
presets && stacked && !compact && "rounded-t-none -mt-[1px]",
presets && compact && "rounded-r-none -mr-[1px]",
compact ? "w-[180px] gap-1.5" : "w-[250px]"
)}
prefix={<CalendarIcon />}
type="secondary"
onClick={() => setIsOpen((prevState) => !prevState)}
>
<div className="truncate pr-4">
{value?.start && value?.end ?
formatDateRange(value.start, value.end, selectedTimezone)
: "选择时间范围"
}
</div>
</Button>
{value?.start && value?.end && (
<Button
aria-label="Clear input value"
svgOnly
variant="unstyled"
className="absolute right-0 top-1/2 -translate-y-1/2 fill-gray-700 hover:fill-gray-1000"
onClick={() => onChange(null)}
>
<ClearIcon />
</Button>
)}
</div>
</div>
</div>
{isOpen && (
<Material
ref={calendarRef}
type="menu"
className={twMerge(clsx(
"p-3 font-sans absolute top-12 z-10",
horizontalLayout ? "w-[462px]" : "w-[280px]",
presets && !stacked && !compact && "left-[250px]",
presets && stacked && "top-[88px]",
popoverAlignment === "center" && "left-[125px] -translate-x-1/2",
popoverAlignment === "end" && "left-[250px] -translate-x-full"
))}
>
<div className={clsx(horizontalLayout && "flex gap-5")}>
<div>
<div className="flex justify-between items-center mb-3">
<h2 className="text-sm text-gray-1000 font-medium">
{formatInTimeZone(currentDate, selectedTimezone, "MMMM yyyy")}
</h2>
<div className="flex gap-0.5">
<Button variant="unstyled" onClick={prevMonth}><ArrowLeftIcon /></Button>
<Button variant="unstyled" onClick={nextMonth}><ArrowRightIcon /></Button>
</div>
</div>
<div className="grid grid-cols-7 text-center text-xs text-gray-900 uppercase mb-2">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div className="grid grid-cols-7 items-center gap-y-2">
{daysArray.map((day) => {
const isStart = value?.start && isSameDay(day, value.start);
const isEnd = value?.end && isSameDay(day, value.end);
const currentHover = hoverDate && isSelecting && isSameDay(day, hoverDate);
const isInRange =
value?.start &&
((value.end && isWithinInterval(day, { start: value.start, end: value.end })) ||
(hoverDate && isWithinInterval(day, { start: value.start, end: hoverDate })));
const isAllowedDate = (minValue ? day >= minValue : true) && (maxValue ? day <= maxValue : true);
return (
<div
key={day.toString()}
className={clsx(
"flex items-center justify-center text-sm text-center rounded transition",
isSameMonth(day, currentDate) && isAllowedDate ? "bg-background-100 text-gray-1000" : "bg-background-100 text-gray-700",
isInRange && !isStart && !isEnd && !currentHover && "!bg-accents-2 rounded-none",
isAllowedDate ? "cursor-pointer" : "cursor-not-allowed"
)}
onMouseEnter={() => isAllowedDate && handleMouseEnter(day)}
onClick={() => isAllowedDate && handleDateClick(day)}
>
<div className={clsx(
"h-8 w-8 flex items-center justify-center rounded",
(isStart || isEnd || currentHover) && isAllowedDate && " !bg-gray-1000 !text-background-100",
!isStart && !isEnd && !currentHover && !isToday(day) && isAllowedDate && "hover:text-gray-1000 hover:border hover:border-gray-alpha-500",
currentHover && isAllowedDate && " !shadow-focus-calendar-date",
isToday(day) && " !bg-blue-900 !text-background-100"
)}>
{format(day, "d")}
</div>
</div>
);
})}
</div>
</div>
{/* <div className={clsx(
"flex flex-col gap-2",
horizontalLayout ? "justify-between" : "mt-3 -mx-3 px-3 pt-2.5 border-t border-gray-alpha-100"
)}>
<div className="flex flex-col gap-2">
<div>
<div className="text-[13px] text-gray-900 capitalize">Start</div>
<div className="grid grid-cols-3 gap-2 mt-1">
<div className={showTimeInput ? "col-span-2" : "col-span-3"}>
<Input
size="small"
value={startDate}
onChange={(value) => setStartDate(value)}
error={startDateError}
/>
</div>
{showTimeInput && (
<Input
size="small"
value={startTime}
onChange={(value) => setStartTime(value)}
error={startTimeError}
/>
)}
</div>
</div>
<div>
<div className="text-[13px] text-gray-900 capitalize">End</div>
<div className="grid grid-cols-3 gap-2 mt-1">
<div className={showTimeInput ? "col-span-2" : "col-span-3"}>
<Input
size="small"
value={endDate}
onChange={(value) => setEndDate(value)}
error={endDateError}
/>
</div>
{showTimeInput && (
<Input
size="small"
value={endTime}
onChange={(value) => setEndTime(value)}
error={endTimeError}
/>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="font-medium flex flex-col">
<Button
type="secondary"
size="small"
suffix={<span className="mt-1 text-xs">↵</span>}
onClick={onApply}
>
Apply
</Button>
</div>
<div className="w-fit self-center">
<Select
size="xsmall"
variant="ghost"
options={timezones}
value={selectedTimezone}
onChange={(event) => setSelectedTimezone(event.target.value)}
/>
</div>
</div>
</div> */}
</div>
</Material>
)}
</div>
);
};

View File

@@ -0,0 +1,81 @@
import React from "react";
const ErrorIcon = () => (
<svg
height="16"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="16"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.30761 1.5L1.5 5.30761L1.5 10.6924L5.30761 14.5H10.6924L14.5 10.6924V5.30761L10.6924 1.5H5.30761ZM5.10051 0C4.83529 0 4.58094 0.105357 4.3934 0.292893L0.292893 4.3934C0.105357 4.58094 0 4.83529 0 5.10051V10.8995C0 11.1647 0.105357 11.4191 0.292894 11.6066L4.3934 15.7071C4.58094 15.8946 4.83529 16 5.10051 16H10.8995C11.1647 16 11.4191 15.8946 11.6066 15.7071L15.7071 11.6066C15.8946 11.4191 16 11.1647 16 10.8995V5.10051C16 4.83529 15.8946 4.58093 15.7071 4.3934L11.6066 0.292893C11.4191 0.105357 11.1647 0 10.8995 0H5.10051ZM8.75 3.75V4.5V8L8.75 8.75H7.25V8V4.5V3.75H8.75ZM8 12C8.55229 12 9 11.5523 9 11C9 10.4477 8.55229 10 8 10C7.44772 10 7 10.4477 7 11C7 11.5523 7.44772 12 8 12Z"
/>
</svg>
);
const ErrorLinkIcon = () => (
<svg
height="16"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="16"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.5 9.75V11.25C11.5 11.3881 11.3881 11.5 11.25 11.5H4.75C4.61193 11.5 4.5 11.3881 4.5 11.25L4.5 4.75C4.5 4.61193 4.61193 4.5 4.75 4.5H6.25H7V3H6.25H4.75C3.7835 3 3 3.7835 3 4.75V11.25C3 12.2165 3.7835 13 4.75 13H11.25C12.2165 13 13 12.2165 13 11.25V9.75V9H11.5V9.75ZM8.5 3H9.25H12.2495C12.6637 3 12.9995 3.33579 12.9995 3.75V6.75V7.5H11.4995V6.75V5.56066L8.53033 8.52978L8 9.06011L6.93934 7.99945L7.46967 7.46912L10.4388 4.5H9.25H8.5V3Z"
/>
</svg>
);
interface Error {
message: string;
action: string;
link: string;
}
interface ErrorProps {
error?: Error;
label?: string;
size?: "small" | "medium" | "large";
children?: React.ReactNode;
}
export const Error = ({ error, label, size = "medium", children }: ErrorProps) => {
return (
<div
className={
`flex items-center gap-2 text-red-900 fill-red-900 font-sans
${{
small: "text-[13px] leading-5",
medium: "text-sm",
large: "text-base"
}[size]}`
}
// @ts-ignore
style={{ "--geist-link-color": "var(--ds-red-900)" }}
>
<ErrorIcon />
{error ? (
<>
{error.message}
<a
className="font-medium flex items-center gap-0.5 -ml-1 hover:no-underline hover:opacity-60 duration-150 relative after:content-[''] after:absolute after:left-0 after:bottom-0 after:w-full after:h-[1px] after:bg-red-900"
href={error.link}
target="_blank"
>
{error.action}
<ErrorLinkIcon />
</a>
</>
) : (
<>
{label && <span className="font-medium">{label}:</span>}
{children}
</>
)}
</div>
);
};

View File

@@ -0,0 +1,120 @@
import React, { useEffect, useRef, useState } from "react";
import { Error } from "@/components/ui/error";
import clsx from "clsx";
const sizes = {
xSmall: "h-6 text-xs rounded-md",
small: "h-8 text-sm rounded-md",
mediumSmall: "h-10 text-sm rounded-md",
medium: "h-10 text-sm rounded-md",
large: "h-12 text-base rounded-lg"
};
interface InputProps {
placeholder?: string;
size?: keyof typeof sizes;
prefix?: React.ReactNode | string;
suffix?: React.ReactNode | string;
prefixStyling?: boolean | string;
suffixStyling?: boolean | string;
disabled?: boolean;
error?: string | boolean;
label?: string;
value?: string;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
ref?: React.RefObject<HTMLInputElement | null>;
className?: string;
wrapperClassName?: string;
}
export const Input = ({
placeholder,
size = "medium",
prefix,
suffix,
prefixStyling = true,
suffixStyling = true,
disabled = false,
error,
label,
value,
onChange,
onFocus,
onBlur,
ref,
className,
wrapperClassName,
...rest
}: InputProps) => {
const [_value, set_value] = useState(value || "");
const _ref = ref ? ref : useRef<HTMLInputElement>(null);
const _onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
set_value(e.target.value);
if (onChange) {
onChange(e.target.value);
}
};
useEffect(() => {
if (value !== undefined) {
set_value(value);
}
}, [value]);
return (
<div className="flex flex-col gap-2" onClick={() => _ref.current?.focus()}>
{label && (
<div className="capitalize text-[13px] text-gray-900">
{label}
</div>
)}
<div className={clsx(
"flex items-center duration-150 font-sans",
error ? "shadow-error-input hover:shadow-error-input-hover" : "border border-gray-alpha-400 hover:border-gray-alpha-500 focus-within:border-transparent focus-within:shadow-focus-input",
sizes[size],
disabled ? "cursor-not-allowed bg-gray-100" : "bg-background-100",
wrapperClassName
)}>
{prefix && (
<div
className={clsx(
"text-gray-700 fill-gray-700 h-full flex items-center justify-center",
prefixStyling === true ? "bg-background-200 border-r border-gray-alpha-400 px-3" : `pl-3${!prefixStyling ? "" : ` ${prefixStyling}`}`,
size === "large" ? "rounded-l-lg" : "rounded-l-md"
)}>
{prefix}
</div>
)}
<input
className={clsx(
"w-full inline-flex appearance-none placeholder:text-gray-900 placeholder:opacity-70 outline-none",
(size === "xSmall" || size === "mediumSmall") ? "px-2" : "px-3",
disabled ? "cursor-not-allowed bg-gray-100 text-gray-700" : "bg-background-100 text-geist-foreground",
className
)}
placeholder={placeholder}
disabled={disabled}
value={_value}
onChange={_onChange}
onFocus={onFocus}
onBlur={onBlur}
ref={_ref}
{...rest}
/>
{suffix && (
<div className={clsx(
"text-gray-700 fill-gray-700 h-full flex items-center justify-center",
suffixStyling === true ? "bg-background-200 border-l border-gray-alpha-400 px-3" : `pr-3 ${!suffixStyling ? "" : ` ${suffixStyling}`}`,
size === "large" ? "rounded-r-lg" : "rounded-r-md"
)}>
{suffix}
</div>
)}
</div>
{typeof error === "string" && <Error size={size === "large" ? "large" : "small"}>{error}</Error>}
</div>
);
};

View File

@@ -0,0 +1,39 @@
import React from "react";
import clsx from "clsx";
const types = {
base: "rounded-md shadow-border",
small: "rounded-md shadow-border-small",
medium: "rounded-xl shadow-border-medium",
large: "rounded-xl shadow-border-large",
tooltip: "rounded-md shadow-tooltip",
menu: "rounded-xl shadow-menu",
modal: "rounded-xl shadow-modal",
fullscreen: "rounded-2xl shadow-fullscreen"
};
interface MaterialProps {
type: keyof typeof types;
children: React.ReactNode;
className?: string;
ref?: React.Ref<HTMLDivElement>;
style?: React.CSSProperties;
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
}
export const Material = ({ type, children, className, ref, style, onClick }: MaterialProps) => {
return (
<div
className={clsx(
"bg-background-100",
types[type],
className
)}
ref={ref}
style={style}
onClick={onClick}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,140 @@
import React from "react";
import { Error } from "@/components/ui/error";
import clsx from "clsx";
const sizes = [
{
xsmall: "h-6 text-xs pl-1.5 pr-[22px]",
small: "h-8 text-sm pl-3 pr-9",
medium: "h-10 text-sm pl-3 pr-9",
large: "h-12 text-base pl-3 pr-9 rounded-lg"
},
{
xsmall: "h-6 text-xs px-[22px]",
small: "h-8 text-sm px-9",
medium: "h-10 text-sm px-9",
large: "h-12 text-base px-9 rounded-lg"
}
];
const variants = {
default: "",
ghost: ""
};
export interface Option {
value: string;
label: string;
}
interface SelectProps {
variant?: keyof typeof variants;
options?: Option[];
label?: string;
value?: string;
placeholder?: string;
size?: keyof typeof sizes[0];
prefix?: React.ReactNode;
suffix?: React.ReactNode;
disabled?: boolean;
error?: string;
onChange?: React.ChangeEventHandler<HTMLSelectElement>;
}
const ArrowBottom = () => (
<svg
height="16"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="16"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.0607 5.49999L13.5303 6.03032L8.7071 10.8535C8.31658 11.2441 7.68341 11.2441 7.29289 10.8535L2.46966 6.03032L1.93933 5.49999L2.99999 4.43933L3.53032 4.96966L7.99999 9.43933L12.4697 4.96966L13 4.43933L14.0607 5.49999Z"
/>
</svg>
);
export const Select = ({
variant = "default",
options,
label,
value,
placeholder,
size = "medium",
suffix,
prefix,
disabled = false,
error,
onChange
}: SelectProps) => {
return (
<div>
{label && (
<label
htmlFor="select"
className="cursor-text block font-sans text-[13px] text-gray-900 capitalize mb-2"
>
{label}
</label>
)}
<div className={clsx(
"relative flex items-center",
disabled ? "fill-[#8f8f8f]" : "fill-[#666666] dark:fill-[#a1a1a1] hover:fill-[#171717] hover:dark:fill-[#ededed]"
)}>
<style>
{`
.xsmallIconContainer svg {
width: 16px;
height: 12px;
}
.smallIconContainer, .mediumIconContainer, .largeIconContainer svg {
width: 16px;
height: 16px;
}
`}
</style>
<select
id="select"
disabled={disabled}
value={value}
onChange={onChange}
className={clsx(
"font-sans appearance-none w-full border rounded-[5px] duration-200 outline-none",
sizes[prefix ? 1 : 0][size],
disabled ? "cursor-not-allowed bg-gray-100 text-gray-700" : variant === "default" ? "text-gray-1000 bg-background-100 cursor-pointer" : "bg-transparent text-accents-5",
error ? "border-error ring-red-900-alpha-160 ring-opacity-100 ring-[3px]" : `ring-gray-alpha-500 ring-opacity-100 focus:ring-[3px] ${variant === "default" ? "border-gray-alpha-400" : "border-transparent ring-none"}`
)}
>
{placeholder && <option value="" disabled selected>{placeholder}</option>}
{options && options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{prefix && (
<span className={clsx(
`inline-flex absolute pointer-events-none duration-150 ${size}IconContainer`,
size === "xsmall" ? "left-[5px]" : "left-3"
)}>
{prefix}
</span>
)}
<span
className={clsx(
`inline-flex absolute pointer-events-none duration-150 ${size}IconContainer`,
size === "xsmall" ? "right-[5px]" : "right-3"
)}>
{suffix ? suffix : <ArrowBottom />}
</span>
</div>
{error && (
<div className="mt-2">
<Error size={size === "large" ? "large" : "small"}>{error}</Error>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,73 @@
import React from "react";
interface SpinnerProps {
size?: number;
color?: string;
}
const bars = [
{
animationDelay: "-1.2s",
transform: "rotate(.0001deg) translate(146%)"
},
{
animationDelay: "-1.1s",
transform: "rotate(30deg) translate(146%)"
},
{
animationDelay: "-1.0s",
transform: "rotate(60deg) translate(146%)"
},
{
animationDelay: "-0.9s",
transform: "rotate(90deg) translate(146%)"
},
{
animationDelay: "-0.8s",
transform: "rotate(120deg) translate(146%)"
},
{
animationDelay: "-0.7s",
transform: "rotate(150deg) translate(146%)"
},
{
animationDelay: "-0.6s",
transform: "rotate(180deg) translate(146%)"
},
{
animationDelay: "-0.5s",
transform: "rotate(210deg) translate(146%)"
},
{
animationDelay: "-0.4s",
transform: "rotate(240deg) translate(146%)"
},
{
animationDelay: "-0.3s",
transform: "rotate(270deg) translate(146%)"
},
{
animationDelay: "-0.2s",
transform: "rotate(300deg) translate(146%)"
},
{
animationDelay: "-0.1s",
transform: "rotate(330deg) translate(146%)"
}
];
export const Spinner = ({ size = 20, color = "#8f8f8f" }: SpinnerProps) => {
return (
<div style={{ width: size, height: size }}>
<div className="relative top-1/2 left-1/2" style={{ width: size, height: size }}>
{bars.map((item) => (
<div
key={item.transform}
className="absolute h-[8%] w-[24%] -left-[10%] -top-[3.9%] rounded-[5px] animate-fade-spin"
style={{ backgroundColor: color, ...item }}
/>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,19 @@
import { RefObject, useEffect } from "react";
export const useClickOutside = (ref: RefObject<any>, callback: () => void) => {
useEffect(() => {
function handleClickOutside(event: Event) {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchstart", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchstart", handleClickOutside);
};
}, [ref]);
};

View File

@@ -1,3 +1,8 @@
@import "tailwindcss";
@import "tw-animate-css";
/* ---break---*/
@custom-variant dark (&:is(.dark *));
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -5,15 +10,99 @@ html {
-webkit-text-size-adjust: 100%;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
@layer base{
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
:root {
--font-gilory: 'gilroy';
--font-HarmonyOS: 'HarmonyOS';
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--context-card-border: hsla(0, 0%, 92%, 1);
--ds-blue-100: oklch(97.32% 0.0141 251.56);
--ds-blue-900: oklch(53.18% 0.2399 256.9900584162342);
--ds-red-100: oklch(96.5% 0.0223 13.09);
--ds-red-900: oklch(54.99% 0.232 25.29);
--ds-amber-100: oklch(97.48% 0.0331 85.79);
--ds-amber-900: oklch(52.79% 0.1496 54.65);
--ds-gray-100: hsla(0, 0%, 95%, 1);
--ds-gray-1000: hsla(0, 0%, 9%, 1);
--ds-gray-alpha-400: hsla(0, 0%, 0%, 0.08);
--ds-background-100: hsla(0, 0%, 100%, 1);
--color-red-900: oklch(54.99% 0.232 25.29);
--ds-shadow-border: 0 0 0 1px rgba(0, 0, 0, 0.08);
--ds-shadow-small: 0px 2px 2px rgba(0, 0, 0, 0.04);
--ds-shadow-border-small: var(--ds-shadow-border), var(--ds-shadow-small);
--ds-shadow-medium: 0px 2px 2px rgba(0, 0, 0, 0.04), 0px 8px 8px -8px rgba(0, 0, 0, 0.04);
--ds-shadow-border-medium: var(--ds-shadow-border), var(--ds-shadow-medium);
--ds-shadow-large: 0px 2px 2px rgba(0, 0, 0, 0.04), 0px 8px 16px -4px rgba(0, 0, 0, 0.04);
--ds-shadow-border-large: var(--ds-shadow-border), var(--ds-shadow-large);
--ds-shadow-tooltip: var(--ds-shadow-border), 0px 1px 1px rgba(0, 0, 0, 0.02), 0px 4px 8px rgba(0, 0, 0, 0.04);
--ds-shadow-menu: var(--ds-shadow-border), 0px 1px 1px rgba(0, 0, 0, 0.02), 0px 4px 8px -4px rgba(0, 0, 0, 0.04), 0px 16px 24px -8px rgba(0, 0, 0, 0.06);
--ds-shadow-modal: var(--ds-shadow-border), 0px 1px 1px rgba(0, 0, 0, 0.02), 0px 8px 16px -4px rgba(0, 0, 0, 0.04), 0px 24px 32px -8px rgba(0, 0, 0, 0.06);
--ds-shadow-fullscreen: var(--ds-shadow-border), 0px 1px 1px rgba(0, 0, 0, 0.02), 0px 8px 16px -4px rgba(0, 0, 0, 0.04), 0px 24px 32px -8px rgba(0, 0, 0, 0.06);
--ds-red-800: oklch(58.19% 0.2482 25.15);
--ds-blue-700: oklch(57.61% 0.2508 258.23);
--ds-amber-800: oklch(77.21% 0.1991 64.28);
--ds-amber-850: hsl(33, 96%, 42%);
--ds-gray-400: hsla(0, 0%, 92%, 1);
--ds-gray-700: hsla(0, 0%, 56%, 1);
--ds-gray-1000-h: hsl(0, 0%, 22%);
--ds-gray-alpha-200: hsla(0, 0%, 0%, 0.08);
--ds-focus-color: var(--ds-blue-700);
--ds-focus-ring: 0 0 0 2px var(--ds-background-100), 0 0 0 4px var(--ds-focus-color);
--ds-red-300: oklch(94.33% 0.0369 15.011509923860523);
--ds-red-500: oklch(84.47% 0.1018 17.71);
--ds-gray-900: hsla(0, 0%, 40%, 1);
--ds-gray-alpha-500: hsla(0, 0%, 0%, 0.21);
--ds-gray-alpha-600: hsla(0, 0%, 0%, 0.34);
--ds-background-200: hsla(0, 0%, 98%, 1);
--geist-foreground: #000;
--ds-input-ring: 0 0 0 1px var(--ds-gray-alpha-600), 0 0 0 4px rgba(0, 0, 0, .16);
--ds-input-error-ring: 0 0 0 1px var(--ds-red-900), 0 0 0 4px var(--ds-red-300);
--ds-input-error-hover-ring: 0 0 0 1px var(--ds-red-900), 0 0 0 4px var(--ds-red-500);
--ds-red-900-alpha-160: hsla(358,66%,48%,.16);
--geist-error: #ee0000;
--ds-gray-200: hsla(0, 0%, 92%, 1);
--ds-gray-alpha-100: rgba(0, 0, 0, 0.05);
--ds-gray-alpha-300: hsla(0, 0%, 0%, 0.1);
--accents-2: #eaeaea;
--ds-focus-calendar-date-ring: 0 0 0 1px var(--ds-background-100), 0 0 0 2.5px var(--ds-gray-1000);
}
body {
@@ -78,3 +167,227 @@ select:-webkit-autofill {
-webkit-text-fill-color: var(--mui-palette-text-primary) !important;
transition: background-color 5000s ease-in-out 0s !important;
}
/* ---break---*/
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-context-card-border: var(--context-card-border);
--animate-fade-spin: fade-spin 1.2s linear infinite;
--color-ds-background-100: var(----ds-background-100);
--color-ds-gray-1000: var(----ds-gray-1000);
--color-background-100: var(--ds-background-100);
--shadow-border: var(--ds-shadow-border);
--shadow-border-small: var(--ds-shadow-border-small);
--shadow-border-medium: var(--ds-shadow-border-medium);
--shadow-border-large: var(--ds-shadow-border-large);
--shadow-tooltip: var(--ds-shadow-tooltip);
--shadow-menu: var(--ds-shadow-menu);
--shadow-modal: var(--ds-shadow-modal);
--shadow-fullscreen: var(--ds-shadow-fullscreen);
--color-red-800: var(--ds-red-800);
--color-red-900: var(--ds-red-900);
--color-amber-800: var(--ds-amber-800);
--color-amber-850: var(--ds-amber-850);
--color-gray-100: var(--ds-gray-100);
--color-gray-400: var(--ds-gray-400);
--color-gray-700: var(--ds-gray-700);
--color-gray-1000: var(--ds-gray-1000);
--color-gray-1000-h: var(--ds-gray-1000-h);
--color-gray-alpha-200: var(--ds-gray-alpha-200);
--color-gray-alpha-400: var(--ds-gray-alpha-400);
--shadow-focus-ring: var(--ds-focus-ring);
--color-gray-900: var(--ds-gray-900);
--color-gray-alpha-500: var(--ds-gray-alpha-500);
--color-background-200: var(--ds-background-200);
--color-geist-foreground: var(--geist-foreground);
--shadow-focus-input: var(--ds-input-ring);
--shadow-error-input: var(--ds-input-error-ring);
--shadow-error-input-hover: var(--ds-input-error-hover-ring);
--color-red-900-alpha-160: var(--ds-red-900-alpha-160);
--color-error: var(--geist-error);
--color-color-red-900: var(----color-red-900);
--color-blue-900: var(--ds-blue-900);
--color-gray-200: var(--ds-gray-200);
--color-gray-alpha-100: var(--ds-gray-alpha-100);
--color-gray-alpha-300: var(--ds-gray-alpha-300);
--color-accents-2: var(--accents-2);
--shadow-focus-calendar-date: var(--ds-focus-calendar-date-ring);
--ds-focus-calendar-date-ring: var(----ds-focus-calendar-date-ring);
--color-ds-gray-alpha-300: var(----ds-gray-alpha-300);
--color-ds-gray-alpha-100: var(----ds-gray-alpha-100);
--color-ds-gray-200: var(----ds-gray-200);
--color-geist-error: var(----geist-error);
--color-ds-red-900-alpha-160: var(----ds-red-900-alpha-160);
--ds-input-error-hover-ring: var(----ds-input-error-hover-ring);
--ds-input-error-ring: var(----ds-input-error-ring);
--ds-input-ring: var(----ds-input-ring);
--color-ds-background-200: var(----ds-background-200);
--color-ds-gray-alpha-600: var(----ds-gray-alpha-600);
--color-ds-gray-alpha-500: var(----ds-gray-alpha-500);
--color-ds-gray-900: var(----ds-gray-900);
--color-ds-red-500: var(----ds-red-500);
--color-ds-red-300: var(----ds-red-300);
--ds-focus-ring: var(----ds-focus-ring);
--ds-focus-color: var(----ds-focus-color);
--color-ds-gray-alpha-200: var(----ds-gray-alpha-200);
--color-ds-gray-1000-h: var(----ds-gray-1000-h);
--color-ds-gray-700: var(----ds-gray-700);
--color-ds-gray-400: var(----ds-gray-400);
--color-ds-amber-850: var(----ds-amber-850);
--color-ds-amber-800: var(----ds-amber-800);
--color-ds-blue-700: var(----ds-blue-700);
--color-ds-red-800: var(----ds-red-800);
--ds-shadow-fullscreen: var(----ds-shadow-fullscreen);
--ds-shadow-modal: var(----ds-shadow-modal);
--ds-shadow-menu: var(----ds-shadow-menu);
--ds-shadow-tooltip: var(----ds-shadow-tooltip);
--ds-shadow-border-large: var(----ds-shadow-border-large);
--ds-shadow-large: var(----ds-shadow-large);
--ds-shadow-border-medium: var(----ds-shadow-border-medium);
--ds-shadow-medium: var(----ds-shadow-medium);
--ds-shadow-border-small: var(----ds-shadow-border-small);
--ds-shadow-small: var(----ds-shadow-small);
--ds-shadow-border: var(----ds-shadow-border);
--color-ds-gray-alpha-400: var(----ds-gray-alpha-400);
--color-ds-gray-100: var(----ds-gray-100);
--color-ds-amber-900: var(----ds-amber-900);
--color-ds-amber-100: var(----ds-amber-100);
--color-ds-red-900: var(----ds-red-900);
--color-ds-red-100: var(----ds-red-100);
--color-ds-blue-900: var(----ds-blue-900);
--color-ds-blue-100: var(----ds-blue-100);
@keyframes fade-spin {
0% {
opacity: 0.15;
}
100% {
opacity: 1;
}
}
}
/* ---break---*/
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--context-card-border: hsla(0, 0%, 18%, 1);
--ds-blue-100: oklch(22.17% 0.069 259.89);
--ds-blue-900: oklch(71.7% 0.1648 250.79360374054167);
--ds-red-100: oklch(22.1% 0.0657 15.11);
--ds-red-900: oklch(69.96% 0.2136 22.03);
--ds-amber-100: oklch(22.46% 0.0538 76.04);
--ds-amber-900: oklch(77.21% 0.1991 64.28);
--ds-gray-100: hsla(0, 0%, 10%, 1);
--ds-gray-1000: hsla(0, 0%, 93%, 1);
--ds-gray-alpha-400: hsla(0, 0%, 100%, 0.14);
--ds-background-100: hsla(0,0%,4%, 1);
--color-red-900: oklch(69.96% 0.2136 22.03);
--ds-shadow-border: 0 0 0 1px rgba(255, 255, 255, 0.145);
--ds-shadow-small: 0px 1px 2px rgba(0, 0, 0, 0.16);
--ds-shadow-border-small: var(--ds-shadow-border), 0px 1px 2px rgba(0, 0, 0, 0.16);
--ds-shadow-medium: 0px 2px 2px rgba(0, 0, 0, 0.32), 0px 8px 8px -8px rgba(0, 0, 0, 0.16);
--ds-shadow-border-medium: var(--ds-shadow-border), 0px 2px 2px rgba(0, 0, 0, 0.32), 0px 8px 8px -8px rgba(0, 0, 0, 0.16);
--ds-shadow-large: 0px 2px 2px rgba(0, 0, 0, 0.04), 0px 8px 16px -4px rgba(0, 0, 0, 0.04);
--ds-shadow-border-large: var(--ds-shadow-border), 0px 2px 2px rgba(0, 0, 0, 0.04), 0px 8px 16px -4px rgba(0, 0, 0, 0.04);
--ds-shadow-tooltip: var(--ds-shadow-border), 0px 1px 1px rgba(0, 0, 0, 0.02), 0px 4px 8px rgba(0, 0, 0, 0.04);
--ds-shadow-menu: var(--ds-shadow-border), 0px 1px 1px rgba(0, 0, 0, 0.02), 0px 4px 8px -4px rgba(0, 0, 0, 0.04), 0px 16px 24px -8px rgba(0, 0, 0, 0.06);
--ds-shadow-modal: var(--ds-shadow-border), 0px 1px 1px rgba(0, 0, 0, 0.02), 0px 8px 16px -4px rgba(0, 0, 0, 0.04), 0px 24px 32px -8px rgba(0, 0, 0, 0.06);
--ds-shadow-fullscreen: var(--ds-shadow-border), 0px 1px 1px rgba(0, 0, 0, 0.02), 0px 8px 16px -4px rgba(0, 0, 0, 0.04), 0px 24px 32px -8px rgba(0, 0, 0, 0.06);
--ds-red-800: oklch(58.01% 0.227 25.12);
--ds-blue-700: oklch(57.61% 0.2321 258.23);
--ds-amber-800: oklch(77.21% 0.1991 64.28);
--ds-gray-400: hsla(0, 0%, 18%, 1);
--ds-gray-700: hsla(0, 0%, 56%, 1);
--ds-gray-1000-h: hsl(0, 0%, 80%);
--ds-gray-alpha-200: hsla(0, 0%, 100%, 0.09);
--ds-red-300: oklch(31.47% 0.1105 20.96);
--ds-red-500: oklch(40.68% 0.1479 23.16);
--ds-gray-900: hsla(0, 0%, 63%, 1);
--ds-gray-alpha-500: hsla(0, 0%, 100%, 0.24);
--ds-gray-alpha-600: hsla(0, 0%, 100%, 0.51);
--ds-background-200: hsla(0, 0%, 0%, 1);
--geist-foreground: #fff;
--ds-input-ring: 0 0 0 1px var(--ds-gray-alpha-600), 0 0 0 4px rgba(255, 255, 255, .24);
--ds-red-900-alpha-160: hsla(358,100%,69%,.16);
--geist-error: #ff0000;
--ds-gray-200: hsla(0, 0%, 12%, 1);
--ds-gray-alpha-100: rgba(255, 255, 255, 0.06);
--ds-gray-alpha-300: hsla(0, 0%, 100%, 0.13);
--accents-2: #333333;
}
/* ---break---*/
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

6
ui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -7,6 +7,8 @@ import '@/assets/fonts/iconfont';
import './index.css';
import '@/assets/styles/markdown.css';
import { ThemeProvider } from '@c-x/ui';
import { zhCN } from 'date-fns/locale/zh-CN';
import { setDefaultOptions } from 'date-fns';
// 配置 Monaco Editor 环境
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
@@ -51,7 +53,7 @@ import { getRedirectUrl } from './utils';
dayjs.locale('zh-cn');
dayjs.extend(duration);
dayjs.extend(relativeTime);
setDefaultOptions({ locale: zhCN });
const App = () => {
const [user, setUser] = useState<DomainUser | DomainAdminUser | null>(null);
const [loading, setLoading] = useState(true);

View File

@@ -1,28 +1,23 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Grid2 as Grid, styled } from '@mui/material';
import {
getStatisticsDashboard,
getUserCodeRankDashboard,
getTimeStatDashboard,
getCategoryStatDashboard,
getStatisticsDashboard,
getTimeStatDashboard,
getUserCodeRankDashboard,
} from '@/api/Dashboard';
import { SecondTimeRange } from '@/components/ui/calendar';
import {
getRecent60MinutesData,
getRecentDaysData,
getRecent24HoursData as getRecentHoursData,
getTimeRange,
} from '@/utils';
import { Grid2 as Grid, styled } from '@mui/material';
import { useRequest } from 'ahooks';
import { UserCard } from './statisticCard';
import { useMemo } from 'react';
import BarCharts from './barCharts';
import LineCharts from './lineCharts';
import PieCharts from './pieCharts';
import BarCharts from './barCharts';
import { ContributionCard } from './statisticCard';
import {
getRecent90DaysData,
getRecent60MinutesData,
getRecent24HoursData,
} from '@/utils';
import { TimeRange } from '../index';
interface TimeDuration {
duration: number;
precision: 'day' | 'hour';
}
import { ContributionCard, UserCard } from './statisticCard';
export const StyledHighlight = styled('span')(({ theme }) => ({
fontSize: 12,
@@ -30,12 +25,11 @@ export const StyledHighlight = styled('span')(({ theme }) => ({
padding: '0 4px',
}));
const GlobalStatistic = ({ timeRange }: { timeRange: TimeRange }) => {
const [timeDuration, settimeDuration] = useState<TimeDuration>({
duration: timeRange === '90d' ? 90 : 24,
precision: timeRange === '90d' ? 'day' : 'hour',
});
const GlobalStatistic = ({
timeDuration,
}: {
timeDuration: SecondTimeRange;
}) => {
const { data: statisticsData } = useRequest(getStatisticsDashboard);
const { data: userCodeRankData } = useRequest(
@@ -60,15 +54,15 @@ const GlobalStatistic = ({ timeRange }: { timeRange: TimeRange }) => {
} = useRequest(() => getCategoryStatDashboard(timeDuration), {
refreshDeps: [timeDuration],
});
const precision = useMemo(() => getTimeRange(timeDuration), [timeDuration]);
const getRangeData = (
data: Record<string, number>[],
timeRange: TimeRange,
precision: 'day' | 'hour',
label: { keyLabel?: string; valueLabel?: string } = { valueLabel: 'value' }
) => {
return timeRange === '90d'
? getRecent90DaysData(data, label)
: getRecent24HoursData(data, label);
return precision === 'day'
? getRecentDaysData(data, label)
: getRecentHoursData(data, label);
};
const {
@@ -89,21 +83,14 @@ const GlobalStatistic = ({ timeRange }: { timeRange: TimeRange }) => {
} = timeStatData || {};
const label = { valueLabel: 'value' };
return {
userActiveChartData: getRangeData(active_users, timeRange, label),
chatChartData: getRangeData(chats, timeRange, label),
codeCompletionChartData: getRangeData(code_completions, timeRange, label),
codeLineChartData: getRangeData(lines_of_code, timeRange, label),
userActiveChartData: getRangeData(active_users, precision, label),
chatChartData: getRangeData(chats, precision, label),
codeCompletionChartData: getRangeData(code_completions, precision, label),
codeLineChartData: getRangeData(lines_of_code, precision, label),
realTimeTokenChartData: getRecent60MinutesData(real_time_tokens, label),
acceptedPerChartData: getRangeData(accepted_per, timeRange, label),
acceptedPerChartData: getRangeData(accepted_per, precision, label),
};
}, [timeStatData]);
useEffect(() => {
settimeDuration({
duration: timeRange === '90d' ? 90 : 24,
precision: timeRange === '90d' ? 'day' : 'hour',
});
}, [timeRange]);
}, [timeStatData, precision]);
return (
<Grid
@@ -123,7 +110,7 @@ const GlobalStatistic = ({ timeRange }: { timeRange: TimeRange }) => {
data={userActiveChartData}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>
{timeStatData?.total_users || 0}
</StyledHighlight>
@@ -133,20 +120,18 @@ const GlobalStatistic = ({ timeRange }: { timeRange: TimeRange }) => {
/>
</Grid>
<Grid size={3}>
<ContributionCard data={userCodeRankData} timeRange={timeRange} />
<ContributionCard data={userCodeRankData} />
</Grid>
<Grid size={4}>
<PieCharts
title='工作模式-对话任务'
data={categoryStatData.work_mode || []}
extra={timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
/>
</Grid>
<Grid size={4}>
<PieCharts
title='编程语言'
data={categoryStatData.program_language || []}
extra={timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
/>
</Grid>
<Grid size={4}>
@@ -162,7 +147,7 @@ const GlobalStatistic = ({ timeRange }: { timeRange: TimeRange }) => {
data={chatChartData}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>
{timeStatData?.total_chats || 0}
</StyledHighlight>
@@ -177,7 +162,7 @@ const GlobalStatistic = ({ timeRange }: { timeRange: TimeRange }) => {
data={codeCompletionChartData}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>
{timeStatData?.total_completions || 0}
</StyledHighlight>
@@ -192,7 +177,7 @@ const GlobalStatistic = ({ timeRange }: { timeRange: TimeRange }) => {
data={codeLineChartData}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>
{timeStatData?.total_lines_of_code || 0}
</StyledHighlight>
@@ -208,7 +193,7 @@ const GlobalStatistic = ({ timeRange }: { timeRange: TimeRange }) => {
formatValueTooltip={(value) => `${value.toFixed(2)}%`}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>
{(timeStatData?.total_accepted_per || 0).toFixed(2)}
</StyledHighlight>

View File

@@ -1,20 +1,24 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import {
DomainUser,
DomainUserHeatmap,
DomainUserHeatmapResp,
} from '@/api/types';
import Avatar from '@/components/avatar';
import Card from '@/components/card';
import dayjs from 'dayjs';
import { Icon } from '@c-x/ui';
import {
Box,
Stack,
Tooltip,
useTheme,
IconButton,
Menu,
MenuItem,
Stack,
TextField,
Tooltip,
useTheme,
} from '@mui/material';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityCalendar } from 'react-activity-calendar';
import { DomainUserHeatmap, DomainUserHeatmapResp } from '@/api/types';
import { DomainUser } from '@/api/types';
import Avatar from '@/components/avatar';
import { Icon } from '@c-x/ui';
const getRecent1YearData = (
data: DomainUserHeatmap[] = [],
@@ -58,30 +62,34 @@ const useActivityCalendarAutoScroll = () => {
'.react-activity-calendar [style*="overflow"]',
'.react-activity-calendar > div:first-child',
];
let scrollContainer: HTMLElement | null = null;
// 按优先级尝试找到滚动容器
for (const selector of selectors) {
scrollContainer = document.querySelector(selector);
if (scrollContainer && scrollContainer.scrollWidth > scrollContainer.clientWidth) {
if (
scrollContainer &&
scrollContainer.scrollWidth > scrollContainer.clientWidth
) {
break;
}
}
if (scrollContainer) {
// 滚动到最右侧(最新数据)
scrollContainer.scrollLeft = scrollContainer.scrollWidth - scrollContainer.clientWidth;
scrollContainer.scrollLeft =
scrollContainer.scrollWidth - scrollContainer.clientWidth;
}
}, []);
const setupAutoScroll = useCallback(() => {
// 延迟执行确保组件完全渲染
const timeoutId = setTimeout(scrollToLatest, 100);
// 使用ResizeObserver监听容器大小变化
let resizeObserver: ResizeObserver | null = null;
const setupResizeObserver = () => {
const container = document.querySelector('.react-activity-calendar');
if (container && 'ResizeObserver' in window) {
@@ -94,10 +102,10 @@ const useActivityCalendarAutoScroll = () => {
window.addEventListener('resize', scrollToLatest);
}
};
// 延迟设置ResizeObserver确保DOM已渲染
const observerTimeoutId = setTimeout(setupResizeObserver, 150);
// 清理函数
return () => {
clearTimeout(timeoutId);
@@ -109,33 +117,36 @@ const useActivityCalendarAutoScroll = () => {
}
};
}, [scrollToLatest]);
return { setupAutoScroll };
};
// 简化的blockSize计算Hook - 保持原有大小
const useBlockSize = (containerRef: React.RefObject<HTMLElement>) => {
const [blockSize, setBlockSize] = useState(8);
useEffect(() => {
const calculateBlockSize = () => {
if (!containerRef.current) return;
const containerWidth = containerRef.current.offsetWidth;
const baseWidth = 980;
const blockIncrement = 54;
// 只在桌面端进行计算,保持原有逻辑
const increment = Math.max(0, Math.ceil((containerWidth - baseWidth) / blockIncrement));
const increment = Math.max(
0,
Math.ceil((containerWidth - baseWidth) / blockIncrement)
);
setBlockSize(increment + 8);
};
// 初始计算
calculateBlockSize();
// 使用ResizeObserver监听容器大小变化
let resizeObserver: ResizeObserver | null = null;
if ('ResizeObserver' in window && containerRef.current) {
resizeObserver = new ResizeObserver(calculateBlockSize);
resizeObserver.observe(containerRef.current);
@@ -143,7 +154,7 @@ const useBlockSize = (containerRef: React.RefObject<HTMLElement>) => {
// 降级方案
window.addEventListener('resize', calculateBlockSize);
}
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
@@ -152,7 +163,7 @@ const useBlockSize = (containerRef: React.RefObject<HTMLElement>) => {
}
};
}, [containerRef]);
return blockSize;
};
@@ -171,19 +182,35 @@ const MemberInfo = ({
const ref = useRef<HTMLDivElement>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const [searchUser, setSearchUser] = useState('');
const [filterUser, setFilterUser] = useState<DomainUser[]>([]);
useEffect(() => {
if (searchUser) {
setFilterUser(
userList?.filter((item) =>
(item.username || '')
.toLowerCase()
?.includes(searchUser.toLowerCase())
) || []
);
} else {
setFilterUser(userList || []);
}
}, [searchUser, userList]);
// 使用自定义Hooks
const blockSize = useBlockSize(ref as React.RefObject<HTMLElement>);
const { setupAutoScroll } = useActivityCalendarAutoScroll();
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
setSearchUser('');
};
// 设置自动滚动
useEffect(() => {
const cleanup = setupAutoScroll();
@@ -218,9 +245,30 @@ const MemberInfo = ({
anchorEl={anchorEl}
open={open}
onClose={handleClose}
autoFocus={false}
disableAutoFocusItem={false}
slotProps={{
paper: {
sx: {
p: 1,
maxHeight: '200px',
},
},
}}
>
{userList?.map((item) => (
<TextField
label='搜索'
size='small'
onChange={(e) => {
e.stopPropagation();
setSearchUser(e.target.value);
}}
value={searchUser}
sx={{ mb: 1, position: 'sticky', top: '10px', zIndex: 1000 }}
/>
{filterUser?.map((item) => (
<MenuItem
autoFocus={false}
key={item.id}
selected={memberData?.id === item.id}
onClick={() => {

View File

@@ -1,48 +1,41 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Grid2 as Grid } from '@mui/material';
import { useParams } from 'react-router-dom';
import MemberInfo from './memberInfo';
import PieCharts from './pieCharts';
import LineCharts from './lineCharts';
import { RecentActivityCard } from './statisticCard';
import { useRequest } from 'ahooks';
import {
getUserEventsDashboard,
getUserStatDashboard,
getUserHeatmapDashboard,
getUserStatDashboard,
} from '@/api/Dashboard';
import { StyledHighlight } from './globalStatistic';
import { getRecent90DaysData, getRecent24HoursData } from '@/utils';
import { DomainUser } from '@/api/types';
import { TimeRange } from '../index';
import { SecondTimeRange } from '@/components/ui/calendar';
import { getRecent24HoursData, getRecentDaysData, getTimeRange } from '@/utils';
import { Grid2 as Grid } from '@mui/material';
import { useRequest } from 'ahooks';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { StyledHighlight } from './globalStatistic';
import LineCharts from './lineCharts';
import MemberInfo from './memberInfo';
import PieCharts from './pieCharts';
import { RecentActivityCard } from './statisticCard';
interface TimeDuration {
duration: number;
precision: 'day' | 'hour';
}
type Precision = 'day' | 'hour';
const MemberStatistic = ({
memberData,
userList,
onMemberChange,
timeRange,
timeDuration,
}: {
memberData: DomainUser | null;
userList: DomainUser[];
onMemberChange: (data: DomainUser) => void;
timeRange: TimeRange;
timeDuration: SecondTimeRange;
}) => {
const [timeDuration, setTimeDuration] = useState<TimeDuration>({
duration: timeRange === '90d' ? 90 : 24,
precision: timeRange === '90d' ? 'day' : 'hour',
});
const { id } = useParams();
const precision = useMemo(() => getTimeRange(timeDuration), [timeDuration]);
const { data: userEvents } = useRequest(
() =>
getUserEventsDashboard({
user_id: id || '',
precision: timeDuration.precision,
precision,
}),
{
refreshDeps: [id],
@@ -74,20 +67,13 @@ const MemberStatistic = ({
}
);
useEffect(() => {
setTimeDuration({
duration: timeRange === '90d' ? 90 : 24,
precision: timeRange === '90d' ? 'day' : 'hour',
});
}, [timeRange]);
const getRangeData = (
data: Record<string, number>[],
timeRange: TimeRange,
precision: Precision,
label: { keyLabel?: string; valueLabel?: string } = { valueLabel: 'value' }
) => {
return timeRange === '90d'
? getRecent90DaysData(data, label)
return precision === 'day'
? getRecentDaysData(data, label)
: getRecent24HoursData(data, label);
};
@@ -104,14 +90,14 @@ const MemberStatistic = ({
lines_of_code = [],
} = userStat || {};
const label = { valueLabel: 'value' };
const chatChartData = getRangeData(chats, timeRange, label);
const chatChartData = getRangeData(chats, precision, label);
const codeCompletionChartData = getRangeData(
code_completions,
timeRange,
precision,
label
);
const codeLineChartData = getRangeData(lines_of_code, timeRange, label);
const acceptedPerChartData = getRangeData(accepted_per, timeRange, label);
const codeLineChartData = getRangeData(lines_of_code, precision, label);
const acceptedPerChartData = getRangeData(accepted_per, precision, label);
return {
chatChartData,
codeCompletionChartData,
@@ -140,16 +126,11 @@ const MemberStatistic = ({
<Grid size={6}>
<PieCharts
title='工作模式-对话任务'
extra={timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
data={userStat?.work_mode || []}
/>
</Grid>
<Grid size={6}>
<PieCharts
title='编程语言'
extra={timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
data={userStat?.program_language || []}
/>
<PieCharts title='编程语言' data={userStat?.program_language || []} />
</Grid>
</Grid>
<Grid size={3}>
@@ -161,8 +142,7 @@ const MemberStatistic = ({
data={chatChartData}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>{userStat?.total_chats || 0}</StyledHighlight>
<StyledHighlight>{userStat?.total_chats || 0}</StyledHighlight>
</>
}
@@ -174,7 +154,7 @@ const MemberStatistic = ({
data={codeCompletionChartData}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>
{userStat?.total_completions || 0}
</StyledHighlight>
@@ -189,7 +169,7 @@ const MemberStatistic = ({
data={codeLineChartData}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>
{userStat?.total_lines_of_code || 0}
</StyledHighlight>
@@ -205,7 +185,7 @@ const MemberStatistic = ({
formatValueTooltip={(value) => `${value.toFixed(2)}%`}
extra={
<>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
<StyledHighlight>
{(userStat?.total_accepted_per || 0).toFixed(2)}
</StyledHighlight>

View File

@@ -1,18 +1,17 @@
import { useState } from 'react';
import { styled, Stack, Box } from '@mui/material';
import { Empty } from '@c-x/ui';
import { Box, Stack, styled } from '@mui/material';
import dayjs from 'dayjs';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { TimeRange } from '../index';
import ContributionModal from './contributionModal';
import Card from '@/components/card';
import {
DomainStatistics,
DomainUserCodeRank,
DomainUserEvent,
} from '@/api/types';
import Avatar from '@/components/avatar';
import Card from '@/components/card';
const StyledCardLabel = styled('div')(({ theme }) => ({
fontSize: '14px',
@@ -58,10 +57,8 @@ export const StyledSerialNumber = styled('span')<{ num: number }>(
export const ContributionCard = ({
data = [],
timeRange,
}: {
data?: DomainUserCodeRank[];
timeRange: TimeRange;
}) => {
const navigate = useNavigate();
const [contributionModalOpen, setContributionModalOpen] = useState(false);
@@ -92,9 +89,6 @@ export const ContributionCard = ({
</Box>
</Stack>
<Box sx={{ fontSize: 12, color: 'text.tertiary' }}>
{timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}
</Box>
</Stack>
<Box

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { getListUser } from '@/api/User';
import { Stack, MenuItem, Select } from '@mui/material';
import { Stack, MenuItem, Select, Box } from '@mui/material';
import { CusTabs } from '@c-x/ui';
import GlobalStatistic from './components/globalStatistic';
import { useRequest } from 'ahooks';
@@ -9,7 +9,41 @@ import { useParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { DomainUser } from '@/api/types';
import { v1LicenseList } from '@/api';
import { Calendar, RangeValue } from '@/components/ui/calendar';
import { endOfDay, startOfDay, subDays, subMonths, subWeeks } from 'date-fns';
const get24HoursRange = () => {
return {
start: startOfDay(subDays(new Date(), 1)),
end: endOfDay(new Date()),
};
};
const presets = {
'last-1-days': {
text: '最近 24 小时',
...get24HoursRange(),
},
'last-3-days': {
text: '最近 3 天',
start: startOfDay(subDays(new Date(), 3)),
end: endOfDay(new Date()),
},
'last-7-days': {
text: '最近 7 天',
start: startOfDay(subWeeks(new Date(), 1)),
end: endOfDay(new Date()),
},
'last-14-days': {
text: '最近 14 天',
start: startOfDay(subWeeks(new Date(), 2)),
end: endOfDay(new Date()),
},
'last-month': {
text: '最近 1 月',
start: startOfDay(subMonths(new Date(), 1)),
end: endOfDay(new Date()),
},
};
export type TimeRange = '90d' | '24h';
const Dashboard = () => {
@@ -17,10 +51,13 @@ const Dashboard = () => {
const { tab, id } = useParams();
const [tabValue, setTabValue] = useState(tab || 'global');
const [memberData, setMemberData] = useState<DomainUser | null>(null);
const [timeRange, setTimeRange] = useState<TimeRange>('24h');
const [timeRange, setTimeRange] = useState<RangeValue>(
presets['last-1-days']
);
const license = useRequest(() => {
return v1LicenseList({})
}).data
return v1LicenseList({});
}).data;
const { data: userData, refresh } = useRequest(
() =>
getListUser({
@@ -57,10 +94,24 @@ const Dashboard = () => {
setTabValue(value);
navigate(`/dashboard/${value}`);
};
const handleTimeRangeChange = (value: any) => {
if (value) {
setTimeRange(value);
} else {
setTimeRange(get24HoursRange());
}
};
const secondValue = useMemo(() => {
return {
start_at: timeRange.start
? Math.floor(timeRange.start?.getTime() / 1000)
: 0,
end_at: timeRange.end ? Math.floor(timeRange.end?.getTime() / 1000) : 0,
};
}, [timeRange]);
return (
<Stack gap={2} sx={{ height: '100%' }}>
<Stack direction='row' justifyContent='space-between' alignItems='center'>
<Stack direction='row' gap={2} alignItems='center'>
<CusTabs
value={tabValue}
onChange={onTabChange}
@@ -84,26 +135,24 @@ const Dashboard = () => {
},
}}
/>
<Select
labelId='time-range-label'
value={timeRange}
size='small'
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
sx={{ fontSize: 14 }}
disabled={license?.edition !== 2}
>
<MenuItem value='24h'> 24 </MenuItem>
<MenuItem value='90d'> 90 </MenuItem>
</Select>
<Box sx={{ py: '4px', pr: 5 }}>
<Calendar
isDocsPage
disabled={license?.edition !== 2}
onChange={handleTimeRangeChange}
presets={presets}
value={timeRange}
/>
</Box>
</Stack>
{tabValue === 'global' && <GlobalStatistic timeRange={timeRange} />}
{tabValue === 'global' && <GlobalStatistic timeDuration={secondValue} />}
{tabValue === 'member' && (
<MemberStatistic
memberData={memberData}
userList={userList}
onMemberChange={onMemberChange}
timeRange={timeRange}
timeDuration={secondValue}
/>
)}
</Stack>

View File

@@ -15,6 +15,7 @@ import {
Select,
Chip,
SelectChangeEvent,
TextField,
} from '@mui/material';
import { Table, Modal, message } from '@c-x/ui';
import { ColumnsType } from '@c-x/ui/dist/Table';
@@ -33,9 +34,18 @@ const GroupList = () => {
const groupData = useRequest(() => getListUserGroup({page: 1, size: 999}));
const userData = useRequest(() => getListUser({}));
const adminData = useRequest(() => getListAdminUser({}));
const [searchUser, setSearchUser] = useState('');
const [data, setData] = useState<DomainUserGroup[]>([]);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
useEffect(()=>{
if(searchUser){
setData(groupData?.data?.groups?.filter((item)=>
(item.name ||'').toLowerCase()?.includes(searchUser.toLowerCase())) || []);
}else {
setData(groupData?.data?.groups || []);
}
},[searchUser, groupData])
const handleClick = (
event: React.MouseEvent<HTMLButtonElement>,
record: DomainUserGroup
@@ -275,6 +285,7 @@ const GroupList = () => {
>
<Box sx={{ fontWeight: 700 }}></Box>
<Stack direction='row' gap={1}>
<TextField label='搜索' size='small' onChange={(e)=>setSearchUser(e.target.value)} />
<Button
variant='contained'
color='primary'
@@ -285,7 +296,7 @@ const GroupList = () => {
<Table
columns={columns}
height='calc(100% - 53px)'
dataSource={groupData.data?.groups || []}
dataSource={data}
sx={{ mx: -2 }}
pagination={false}
rowKey='id'

View File

@@ -147,13 +147,21 @@ const ResetPasswordModal = ({
};
const MemberManage = () => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [resetPasswordOpen, setResetPasswordOpen] = useState(false);
const [currentUser, setCurrentUser] = useState<DomainUser | null>(null);
const { data, loading, refresh } = useRequest(() => getListUser({page: 1, size: 999}));
const { data: originData, loading, refresh } = useRequest(() => getListUser({page: 1, size: 999}));
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [searchUser, setSearchUser] = useState('');
const [data, setData] = useState<DomainUser[]>([]);
useEffect(()=>{
if(searchUser){
setData(originData?.users?.filter((item)=>
(item.username ||'').toLowerCase()?.includes(searchUser.toLowerCase())) || []);
}else {
setData(originData?.users || []);
}
},[searchUser, originData])
const handleClick = (
event: React.MouseEvent<HTMLButtonElement>,
record: DomainUser
@@ -332,7 +340,7 @@ const MemberManage = () => {
>
<Box sx={{ fontWeight: 700 }}></Box>
<Stack direction='row' gap={1}>
<TextField label='搜索' size='small' />
<TextField label='搜索' size='small' onChange={(e)=>setSearchUser(e.target.value)} />
<Button
variant='contained'
color='primary'
@@ -345,7 +353,7 @@ const MemberManage = () => {
<Table
columns={columns}
height='calc(100% - 53px)'
dataSource={data?.users || []}
dataSource={data || []}
sx={{ mx: -2 }}
pagination={false}
rowKey='id'

View File

@@ -4,7 +4,7 @@ import { Stack, Box, styled } from '@mui/material';
import LineCharts from './lineCharts';
import { useRequest } from 'ahooks';
import { getGetTokenUsage } from '@/api/Model';
import { addCommasToNumber, getRecent90DaysData } from '@/utils';
import { addCommasToNumber, getRecentDaysData } from '@/utils';
import { DomainModelTokenUsage } from '@/api/types';
export const StyledHighlight = styled('span')(({ theme }) => ({
@@ -35,10 +35,10 @@ const TokenUsage = () => {
const { llmInputData, llmOutputData } = useMemo(() => {
return {
llmInputData: getRecent90DaysData(
llmInputData: getRecentDaysData(
(llmModelData?.input_usage as Required<DomainModelTokenUsage>[]) || []
),
llmOutputData: getRecent90DaysData(
llmOutputData: getRecentDaysData(
(llmModelData?.output_usage as Required<DomainModelTokenUsage>[]) || []
),
};
@@ -46,10 +46,10 @@ const TokenUsage = () => {
const { coderInputData, coderOutputData } = useMemo(() => {
return {
coderInputData: getRecent90DaysData(
coderInputData: getRecentDaysData(
(coderModelData?.input_usage as Required<DomainModelTokenUsage>[]) || []
),
coderOutputData: getRecent90DaysData(
coderOutputData: getRecentDaysData(
(coderModelData?.output_usage as Required<DomainModelTokenUsage>[]) ||
[]
),

View File

@@ -11,7 +11,7 @@ import {
getUserDashboardStat,
} from '@/api/UserDashboard';
import { StyledHighlight } from '@/pages/dashboard/components/globalStatistic';
import { getRecent90DaysData, getRecent24HoursData } from '@/utils';
import { getRecentDaysData, getRecent24HoursData } from '@/utils';
import { DomainUser } from '@/api/types';
import { TimeRange } from '../index';
@@ -68,7 +68,7 @@ const MemberStatistic = ({
label: { keyLabel?: string; valueLabel?: string } = { valueLabel: 'value' }
) => {
return timeRange === '90d'
? getRecent90DaysData(data, label)
? getRecentDaysData(data, label)
: getRecent24HoursData(data, label);
};

View File

@@ -1,5 +1,6 @@
import { Decimal } from 'decimal.js';
import dayjs from 'dayjs';
import { SecondTimeRange } from '@/components/ui/calendar';
/**
* 格式化时间
@@ -154,7 +155,7 @@ export const getRedirectUrl = (source: 'user' | 'admin' = 'admin') => {
return redirectUrl as URL;
};
export const getRecent90DaysData = (
export const getRecentDaysData = (
data: Record<string, number>[] = [],
label: { keyLabel?: string; valueLabel?: string } = {}
) => {
@@ -265,3 +266,11 @@ export const getBaseLanguageId = (languageId: string): string => {
};
return map[languageId] || languageId;
};
export const getTimeRange = (timeDuration: SecondTimeRange) => {
const diff = timeDuration.end_at - timeDuration.start_at;
if (diff > 24 * 60 * 60 * 1000) {
return 'day';
}
return 'hour';
};

View File

@@ -3,5 +3,11 @@
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -1,6 +1,7 @@
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import tailwindcss from "@tailwindcss/vite"
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
@@ -8,7 +9,7 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [react()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),