feat: add environment variable management page with import/export functionality

- Implemented EnvPage component for managing environment variables.
- Added functionality to load, add, remove, and update environment variables.
- Included validation for empty and duplicate keys.
- Implemented import/export features for environment variables in JSON format.
- Integrated autocompletion for environment variable keys based on predefined config.

feat: create user profile management with edit and password change modals

- Developed ProfileCard component to display user information.
- Added EditProfileModal for updating user details.
- Implemented ChangePasswordModal for password modification.
- Integrated user data fetching and state management using Zustand.

feat: establish user store for managing user state and actions

- Created user store with Zustand for managing user profile state.
- Added actions for updating user information and handling loading states.

feat: implement login store for user authentication

- Developed login store for managing login state and actions.
- Added functionality for user login and registration with error handling.

feat: create reusable UI components for input groups and comboboxes

- Developed InputGroup and related components for enhanced input handling.
- Created Combobox component for improved selection functionality.
- Added Badge component for displaying contextual information.
This commit is contained in:
2026-02-02 23:30:08 +08:00
parent e42fce5bd1
commit 09f5f06baa
14 changed files with 1658 additions and 70 deletions

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./dist/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -10,7 +10,7 @@ const nextConfig: NextConfig = {
distDir: 'dist',
basePath: basePath,
trailingSlash: true,
transpilePackages: ['@kevisual/api'],
transpilePackages: ['@kevisual/api', "@kevisual/use-config"],
images: {
unoptimized: true,
},

View File

@@ -11,10 +11,11 @@
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@kevisual/api": "^0.0.28",
"@base-ui/react": "^1.1.0",
"@kevisual/api": "^0.0.41",
"@kevisual/cache": "^0.0.5",
"@kevisual/query": "^0.0.38",
"@kevisual/router": "^0.0.63",
"@kevisual/query": "^0.0.39",
"@kevisual/router": "^0.0.66",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -39,7 +40,7 @@
"idb-keyval": "^6.2.2",
"lucide-react": "^0.563.0",
"marked": "^17.0.1",
"next": "16.1.5",
"next": "16.1.6",
"react": "19.2.4",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.4",
@@ -48,11 +49,12 @@
"tailwind-merge": "^3.4.0",
"valtio": "^2.3.0",
"vaul": "^1.1.2",
"zustand": "^5.0.10"
"zustand": "^5.0.11"
},
"devDependencies": {
"@kevisual/context": "^0.0.4",
"@kevisual/types": "^0.0.12",
"@kevisual/use-config": "^1.0.30",
"@tailwindcss/postcss": "^4",
"@types/node": "^25",
"@types/react": "^19",

222
pnpm-lock.yaml generated
View File

@@ -11,18 +11,21 @@ importers:
'@ant-design/icons':
specifier: ^6.1.0
version: 6.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@base-ui/react':
specifier: ^1.1.0
version: 1.1.0(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@kevisual/api':
specifier: ^0.0.28
version: 0.0.28
specifier: ^0.0.41
version: 0.0.41
'@kevisual/cache':
specifier: ^0.0.5
version: 0.0.5
'@kevisual/query':
specifier: ^0.0.38
version: 0.0.38
specifier: ^0.0.39
version: 0.0.39
'@kevisual/router':
specifier: ^0.0.63
version: 0.0.63(typescript@5.9.3)
specifier: ^0.0.66
version: 0.0.66(typescript@5.9.3)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -96,8 +99,8 @@ importers:
specifier: ^17.0.1
version: 17.0.1
next:
specifier: 16.1.5
version: 16.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
specifier: 16.1.6
version: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: 19.2.4
version: 19.2.4
@@ -123,8 +126,8 @@ importers:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
zustand:
specifier: ^5.0.10
version: 5.0.10(@types/react@19.2.7)(react@19.2.4)
specifier: ^5.0.11
version: 5.0.11(@types/react@19.2.7)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
devDependencies:
'@kevisual/context':
specifier: ^0.0.4
@@ -132,6 +135,9 @@ importers:
'@kevisual/types':
specifier: ^0.0.12
version: 0.0.12
'@kevisual/use-config':
specifier: ^1.0.30
version: 1.0.30(dotenv@17.2.3)
'@tailwindcss/postcss':
specifier: ^4
version: 4.1.18
@@ -211,6 +217,27 @@ packages:
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@base-ui/react@1.1.0':
resolution: {integrity: sha512-ikcJRNj1mOiF2HZ5jQHrXoVoHcNHdBU5ejJljcBl+VTLoYXR6FidjTN86GjO6hyshi6TZFuNvv0dEOgaOFv6Lw==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@base-ui/utils@0.2.4':
resolution: {integrity: sha512-smZwpMhjO29v+jrZusBSc5T+IJ3vBb9cjIiBjtKcvWmRj9Z4DWGVR3efr1eHR56/bqY5a4qyY9ElkOY5ljo3ng==}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
@@ -407,8 +434,8 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kevisual/api@0.0.28':
resolution: {integrity: sha512-WQluRlu2qGM1qktIhPLODie8x382a6jEMfFOcay/rnkCgXK0BRpnqOKwlX7IMLdMqka7GY/BD69kSMnK1Exf5g==}
'@kevisual/api@0.0.41':
resolution: {integrity: sha512-cNKvdhIQ3Pt98LV0ElprZZRjY9h/l+hcIGzubcD63PtR4Yq9CTaV0d9ARggkzeDkWlFsmH1ALpru1qZ5Zwmn4w==}
'@kevisual/cache@0.0.5':
resolution: {integrity: sha512-fgtUYGUUq/DY0KFV4CkWszNqvQUaA8XvMTUjoR9ZXRpau5IIDolD/Wen2TFsZ7G3Rfy+lef5dnaiZVDkZwdVKg==}
@@ -426,66 +453,71 @@ packages:
'@kevisual/load@0.0.6':
resolution: {integrity: sha512-+3YTFehRcZ1haGel5DKYMUwmi5i6f2psyaPZlfkKU/cOXgkpwoG9/BEqPCnPjicKqqnksEpixVRkyHJ+5bjLVA==}
'@kevisual/query@0.0.38':
resolution: {integrity: sha512-bfvbSodsZyMfwY+1T2SvDeOCKsT/AaIxlVe0+B1R/fNhlg2MDq2CP0L9HKiFkEm+OXrvXcYDMKPUituVUM5J6Q==}
'@kevisual/query@0.0.39':
resolution: {integrity: sha512-3UEPBIvtdykNkrby3hvrgrHdgd17Uq+Pnr4zs+JBzATkU2eKaOqtTUJqdyIEwuySCwzGTxrnlUzWP4tziDQDLQ==}
'@kevisual/router@0.0.63':
resolution: {integrity: sha512-rM/FELiNtTJkjb00sdQ2f8NpWHzfOwrtgQJhsX9Np9KdzAQ5dP6pI5nAHlWvU2QyrC1VH5IibUmsBIeMMV3nWg==}
'@kevisual/router@0.0.66':
resolution: {integrity: sha512-yoiCfKJ8yxrXToh8ud1+/JFqlRexrZmJ0PhofQX3jyfmmyEBQQJFL+2UYewm4FxbG3l7ndBC/NIhu1v5CdwxiQ==}
'@kevisual/types@0.0.12':
resolution: {integrity: sha512-zJXH2dosir3jVrQ6QG4i0+iLQeT9gJ3H+cKXs8ReWboxBSYzUZO78XssVeVrFPsJ33iaAqo4q3DWbSS1dWGn7Q==}
'@next/env@16.1.5':
resolution: {integrity: sha512-CRSCPJiSZoi4Pn69RYBDI9R7YK2g59vLexPQFXY0eyw+ILevIenCywzg+DqmlBik9zszEnw2HLFOUlLAcJbL7g==}
'@kevisual/use-config@1.0.30':
resolution: {integrity: sha512-kPdna0FW/X7D600aMdiZ5UTjbCo6d8d4jjauSc8RMmBwUU6WliFDSPUNKVpzm2BsDX5Nth1IXFPYMqH+wxqAmw==}
peerDependencies:
dotenv: ^17
'@next/swc-darwin-arm64@16.1.5':
resolution: {integrity: sha512-eK7Wdm3Hjy/SCL7TevlH0C9chrpeOYWx2iR7guJDaz4zEQKWcS1IMVfMb9UKBFMg1XgzcPTYPIp1Vcpukkjg6Q==}
'@next/env@16.1.6':
resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
'@next/swc-darwin-arm64@16.1.6':
resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.1.5':
resolution: {integrity: sha512-foQscSHD1dCuxBmGkbIr6ScAUF6pRoDZP6czajyvmXPAOFNnQUJu2Os1SGELODjKp/ULa4fulnBWoHV3XdPLfA==}
'@next/swc-darwin-x64@16.1.6':
resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.1.5':
resolution: {integrity: sha512-qNIb42o3C02ccIeSeKjacF3HXotGsxh/FMk/rSRmCzOVMtoWH88odn2uZqF8RLsSUWHcAqTgYmPD3pZ03L9ZAA==}
'@next/swc-linux-arm64-gnu@16.1.6':
resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.5':
resolution: {integrity: sha512-U+kBxGUY1xMAzDTXmuVMfhaWUZQAwzRaHJ/I6ihtR5SbTVUEaDRiEU9YMjy1obBWpdOBuk1bcm+tsmifYSygfw==}
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.5':
resolution: {integrity: sha512-gq2UtoCpN7Ke/7tKaU7i/1L7eFLfhMbXjNghSv0MVGF1dmuoaPeEVDvkDuO/9LVa44h5gqpWeJ4mRRznjDv7LA==}
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.5':
resolution: {integrity: sha512-bQWSE729PbXT6mMklWLf8dotislPle2L70E9q6iwETYEOt092GDn0c+TTNj26AjmeceSsC4ndyGsK5nKqHYXjQ==}
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.5':
resolution: {integrity: sha512-LZli0anutkIllMtTAWZlDqdfvjWX/ch8AFK5WgkNTvaqwlouiD1oHM+WW8RXMiL0+vAkAJyAGEzPPjO+hnrSNQ==}
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.1.5':
resolution: {integrity: sha512-7is37HJTNQGhjPpQbkKjKEboHYQnCgpVt/4rBrrln0D9nderNxZ8ZWs8w1fAtzUx7wEyYjQ+/13myFgFj6K2Ng==}
'@next/swc-win32-x64-msvc@16.1.6':
resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1504,6 +1536,9 @@ packages:
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/spark-md5@3.0.5':
resolution: {integrity: sha512-lWf05dnD42DLVKQJZrDHtWFidcLrHuip01CtnC2/S6AMhX4t9ZlEUj4iuRlAnts0PQk7KESOqKxeGE/b6sIPGg==}
antd@6.2.2:
resolution: {integrity: sha512-f5RvWnhjt2gZTpBMW3msHwA3IeaCJBHDwVyEsskYGp0EXcRhhklWrltkybDki0ysBNywkjLPp3wuuWhIKfplcQ==}
peerDependencies:
@@ -1749,8 +1784,8 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
next@16.1.5:
resolution: {integrity: sha512-f+wE+NSbiQgh3DSAlTaw2FwY5yGdVViAtp8TotNQj4kk4Q8Bh1sC/aL9aH+Rg1YAVn18OYXsRDT7U/079jgP7w==}
next@16.1.6:
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -1770,6 +1805,9 @@ packages:
sass:
optional: true
path-browserify-esm@1.0.6:
resolution: {integrity: sha512-9nUwYvvu/yq1PYrUyYCihNWmpzacaRYF6gGbjLWErrZ4MRDWyfPN7RpE8E7tsw8eqBU/rr7mcoTXbS+Vih8uUA==}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@@ -1845,6 +1883,9 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
@@ -1887,6 +1928,9 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
string-convert@0.2.1:
resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==}
@@ -1910,6 +1954,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
@@ -1961,6 +2008,11 @@ packages:
'@types/react':
optional: true
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
valtio@2.3.0:
resolution: {integrity: sha512-1MfKNcmOIdBSatiJsYgw420n6jnD+jeoI0V+RkOQbCB0ElLh6GKUfPr0hc9uq/KBGeghivDEarRsKFFdSQQnKw==}
engines: {node: '>=12.20.0'}
@@ -1979,8 +2031,8 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
zustand@5.0.10:
resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==}
zustand@5.0.11:
resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
@@ -2061,6 +2113,31 @@ snapshots:
'@babel/runtime@7.28.6': {}
'@base-ui/react@1.1.0(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@base-ui/utils': 0.2.4(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
reselect: 5.1.1
tabbable: 6.4.0
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.7
'@base-ui/utils@0.2.4(@types/react@19.2.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@floating-ui/utils': 0.2.10
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
reselect: 5.1.1
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.7
'@date-fns/tz@1.4.1': {}
'@emnapi/runtime@1.8.1':
@@ -2205,14 +2282,17 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@kevisual/api@0.0.28':
'@kevisual/api@0.0.41':
dependencies:
'@kevisual/js-filter': 0.0.5
'@kevisual/load': 0.0.6
'@types/spark-md5': 3.0.5
es-toolkit: 1.44.0
eventemitter3: 5.0.4
fuse.js: 7.1.0
nanoid: 5.1.6
path-browserify-esm: 1.0.6
spark-md5: 3.0.2
'@kevisual/cache@0.0.5':
dependencies:
@@ -2239,11 +2319,11 @@ snapshots:
dependencies:
eventemitter3: 5.0.4
'@kevisual/query@0.0.38':
'@kevisual/query@0.0.39':
dependencies:
tslib: 2.8.1
'@kevisual/router@0.0.63(typescript@5.9.3)':
'@kevisual/router@0.0.66(typescript@5.9.3)':
dependencies:
'@kevisual/dts': 0.0.3(typescript@5.9.3)
hono: 4.11.7
@@ -2252,30 +2332,35 @@ snapshots:
'@kevisual/types@0.0.12': {}
'@next/env@16.1.5': {}
'@kevisual/use-config@1.0.30(dotenv@17.2.3)':
dependencies:
'@kevisual/load': 0.0.6
dotenv: 17.2.3
'@next/swc-darwin-arm64@16.1.5':
'@next/env@16.1.6': {}
'@next/swc-darwin-arm64@16.1.6':
optional: true
'@next/swc-darwin-x64@16.1.5':
'@next/swc-darwin-x64@16.1.6':
optional: true
'@next/swc-linux-arm64-gnu@16.1.5':
'@next/swc-linux-arm64-gnu@16.1.6':
optional: true
'@next/swc-linux-arm64-musl@16.1.5':
'@next/swc-linux-arm64-musl@16.1.6':
optional: true
'@next/swc-linux-x64-gnu@16.1.5':
'@next/swc-linux-x64-gnu@16.1.6':
optional: true
'@next/swc-linux-x64-musl@16.1.5':
'@next/swc-linux-x64-musl@16.1.6':
optional: true
'@next/swc-win32-arm64-msvc@16.1.5':
'@next/swc-win32-arm64-msvc@16.1.6':
optional: true
'@next/swc-win32-x64-msvc@16.1.5':
'@next/swc-win32-x64-msvc@16.1.6':
optional: true
'@radix-ui/number@1.1.1': {}
@@ -3277,6 +3362,8 @@ snapshots:
'@types/resolve@1.20.2': {}
'@types/spark-md5@3.0.5': {}
antd@6.2.2(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@ant-design/colors': 8.0.1
@@ -3506,9 +3593,9 @@ snapshots:
nanoid@5.1.6: {}
next@16.1.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.1.5
'@next/env': 16.1.6
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.9.11
caniuse-lite: 1.0.30001762
@@ -3517,19 +3604,21 @@ snapshots:
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(react@19.2.4)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.5
'@next/swc-darwin-x64': 16.1.5
'@next/swc-linux-arm64-gnu': 16.1.5
'@next/swc-linux-arm64-musl': 16.1.5
'@next/swc-linux-x64-gnu': 16.1.5
'@next/swc-linux-x64-musl': 16.1.5
'@next/swc-win32-arm64-msvc': 16.1.5
'@next/swc-win32-x64-msvc': 16.1.5
'@next/swc-darwin-arm64': 16.1.6
'@next/swc-darwin-x64': 16.1.6
'@next/swc-linux-arm64-gnu': 16.1.6
'@next/swc-linux-arm64-musl': 16.1.6
'@next/swc-linux-x64-gnu': 16.1.6
'@next/swc-linux-x64-musl': 16.1.6
'@next/swc-win32-arm64-msvc': 16.1.6
'@next/swc-win32-x64-msvc': 16.1.6
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
path-browserify-esm@1.0.6: {}
path-parse@1.0.7: {}
picocolors@1.1.1: {}
@@ -3597,6 +3686,8 @@ snapshots:
react@19.2.4: {}
reselect@5.1.1: {}
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
@@ -3690,6 +3781,8 @@ snapshots:
source-map-js@1.2.1: {}
spark-md5@3.0.2: {}
string-convert@0.2.1: {}
styled-jsx@5.1.6(react@19.2.4):
@@ -3701,6 +3794,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tabbable@6.4.0: {}
tailwind-merge@3.4.0: {}
tailwindcss@4.1.18: {}
@@ -3734,6 +3829,10 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
use-sync-external-store@1.6.0(react@19.2.4):
dependencies:
react: 19.2.4
valtio@2.3.0(@types/react@19.2.7)(react@19.2.4):
dependencies:
proxy-compare: 3.0.1
@@ -3750,7 +3849,8 @@ snapshots:
- '@types/react'
- '@types/react-dom'
zustand@5.0.10(@types/react@19.2.7)(react@19.2.4):
zustand@5.0.11(@types/react@19.2.7)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
optionalDependencies:
'@types/react': 19.2.7
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)

View File

@@ -0,0 +1,168 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
export interface AutocomplateOption {
value: string
label: string
description?: string
tags?: readonly string[]
}
interface AutocomplateProps {
value: string
onChange: (value: string) => void
options: AutocomplateOption[]
placeholder?: string
searchPlaceholder?: string
emptyMessage?: string
className?: string
}
export function Autocomplate({
value,
onChange,
options,
placeholder = "选择项目...",
searchPlaceholder = "搜索...",
emptyMessage = "未找到结果",
className,
}: AutocomplateProps) {
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState(value)
const [searchValue, setSearchValue] = React.useState("")
React.useEffect(() => {
setInputValue(value)
}, [value])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setInputValue(newValue)
onChange(newValue)
}
const handleSelect = (selectedValue: string) => {
setInputValue(selectedValue)
onChange(selectedValue)
setSearchValue("")
setOpen(false)
}
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && searchValue) {
e.preventDefault()
handleSelect(searchValue)
}
}
// 随机获取badge variant
const getRandomVariant = (index: number) => {
const variants = ['default', 'secondary', 'outline'] as const
return variants[index % variants.length]
}
return (
<div className="flex gap-2">
<Input
value={inputValue}
onChange={handleInputChange}
placeholder={placeholder}
className={cn("flex-1 font-mono", className)}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-10 px-0 shrink-0"
>
<ChevronsUpDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-125 p-0" align="end">
<Command className="**:[[cmdk-input]]:font-mono">
<CommandInput
placeholder={searchPlaceholder}
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={handleSearchKeyDown}
/>
<CommandList>
<CommandEmpty>
<div className="py-6 text-center text-sm">
<div className="text-muted-foreground mb-2">{emptyMessage}</div>
{searchValue && (
<button
onClick={() => handleSelect(searchValue)}
className="text-primary hover:underline text-sm"
>
使 "{searchValue}"
</button>
)}
</div>
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.value, option.label, option.description || '', ...(option.tags || [])]}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
inputValue === option.value ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col flex-1">
<span className="font-medium font-mono">{option.label}</span>
{option.description && (
<span className="text-xs text-muted-foreground">
{option.description}
</span>
)}
{option.tags && option.tags.length > 0 && (
<div className="flex gap-1 flex-wrap mt-1">
{option.tags.map((tag, index) => (
<Badge
key={tag}
variant={getRandomVariant(index)}
className="text-[10px] px-1.5 py-0 h-4"
>
{tag}
</Badge>
))}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}

324
src/app/config/env/page.tsx vendored Normal file
View File

@@ -0,0 +1,324 @@
'use client';
import { configEnvList } from '@kevisual/use-config/env-config.ts';
import { useConfigStore } from '../store/config';
import { useEffect, useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
import { Trash2, Plus, Save, Upload, Download } from 'lucide-react';
import { toast } from 'sonner';
import { Autocomplate, type AutocomplateOption } from '@/app/config/components/autocomplate';
type EnvItem = {
key: string;
value: string;
id: string;
};
export default function EnvPage() {
const { getEnv, updateEnv, envData } = useConfigStore();
const [envItems, setEnvItems] = useState<EnvItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadEnvData();
}, []);
useEffect(() => {
if (envData && typeof envData === 'object') {
const data = envData?.data || {};
const items = Object.entries(data).map(([key, value], index) => ({
key,
value: String(value || ''),
id: `env-${index}-${Date.now()}`,
}));
setEnvItems(items);
}
}, [envData]);
const loadEnvData = async () => {
setIsLoading(true);
try {
await getEnv();
} finally {
setIsLoading(false);
}
};
const addEnvItem = () => {
const newItem: EnvItem = {
key: '',
value: '',
id: `env-new-${Date.now()}`,
};
setEnvItems([...envItems, newItem]);
};
const removeEnvItem = (id: string) => {
setEnvItems(envItems.filter((item) => item.id !== id));
};
const updateEnvItem = (id: string, field: 'key' | 'value', newValue: string) => {
setEnvItems(
envItems.map((item) => (item.id === id ? { ...item, [field]: newValue } : item))
);
};
const handleSave = async () => {
// 验证是否有空的 key
const hasEmptyKey = envItems.some((item) => !item.key.trim());
if (hasEmptyKey) {
toast.error('请填写所有环境变量的键名');
return;
}
// 验证是否有重复的 key
const keys = envItems.map((item) => item.key);
const uniqueKeys = new Set(keys);
if (keys.length !== uniqueKeys.size) {
toast.error('存在重复的环境变量键名');
return;
}
// 转换为对象
const envObject = envItems.reduce(
(acc, item) => {
if (item.key.trim()) {
acc[item.key] = item.value;
}
return acc;
},
{} as Record<string, string>
);
setIsLoading(true);
try {
await updateEnv({ ...envData, data: envObject });
await loadEnvData();
} finally {
setIsLoading(false);
}
};
const getEnvDescription = (key: string): string => {
const config = configEnvList.find((item) => item.title === key);
return config?.description || '';
};
const getEnvTags = (key: string): readonly string[] => {
const config = configEnvList.find((item) => item.title === key);
return config?.tags || [];
};
const handleExport = () => {
// 只导出第一级的 key-value
const envObject = envItems.reduce(
(acc, item) => {
if (item.key.trim()) {
acc[item.key] = item.value;
}
return acc;
},
{} as Record<string, string>
);
const json = JSON.stringify(envObject, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'env-config.json';
a.click();
URL.revokeObjectURL(url);
toast.success('导出成功');
};
const handleImport = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const json = JSON.parse(event.target?.result as string);
// 只读取第一级的 key-value
const items: EnvItem[] = [];
Object.entries(json).forEach(([key, value]) => {
// 只处理第一级,忽略嵌套对象
if (typeof value !== 'object' || value === null) {
items.push({
key,
value: String(value || ''),
id: `env-${items.length}-${Date.now()}`,
});
} else {
// 如果是对象,只取第一级的值
items.push({
key,
value: JSON.stringify(value),
id: `env-${items.length}-${Date.now()}`,
});
}
});
setEnvItems(items);
toast.success('导入成功');
} catch (error) {
toast.error('导入失败,请检查文件格式');
console.error(error);
}
};
reader.readAsText(file);
};
input.click();
};
const comboboxOptions = useMemo<AutocomplateOption[]>(() => {
return configEnvList.map((config) => ({
value: config.title,
label: config.title,
description: config.description,
tags: config.tags,
})).sort((a, b) => {
// 优先排序:有"常用"标签的排在前面
const aHasCommon = a.tags?.some(tag => tag === '常用') ?? false;
const bHasCommon = b.tags?.some(tag => tag === '常用') ?? false;
if (aHasCommon && !bHasCommon) return -1;
if (!aHasCommon && bHasCommon) return 1;
// 其次按字母顺序排序
return a.label.localeCompare(b.label);
});
}, []);
if (isLoading && envItems.length === 0) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">...</div>
</div>
);
}
return (
<div className="h-screen flex flex-col">
<div className="flex-none border-b bg-background">
<div className="container mx-auto p-6 max-w-5xl">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-2"></p>
</div>
<div className="flex gap-2">
<Button onClick={handleImport} variant="outline">
<Upload className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
</Button>
<Button onClick={addEnvItem} variant="outline">
<Plus className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSave} disabled={isLoading}>
<Save className="w-4 h-4 mr-2" />
{isLoading ? '保存中...' : '保存配置'}
</Button>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="container mx-auto p-6 max-w-5xl">
<div className="space-y-4">
{envItems.map((item) => (
<Card key={item.id}>
<CardHeader>
<div className="flex justify-between items-start gap-4">
<div className="flex-1 space-y-4">
<div className="space-y-2">
<Label htmlFor={`key-${item.id}`}></Label>
<Autocomplate
value={item.key}
onChange={(value) => updateEnvItem(item.id, 'key', value)}
options={comboboxOptions}
placeholder="输入或选择环境变量键"
searchPlaceholder="搜索环境变量..."
emptyMessage="未找到匹配的环境变量"
/>
</div>
{item.key && getEnvDescription(item.key) && (
<CardDescription>{getEnvDescription(item.key)}</CardDescription>
)}
{item.key && getEnvTags(item.key).length > 0 && (
<div className="flex gap-2 flex-wrap">
{getEnvTags(item.key).map((tag) => (
<span
key={tag}
className="text-xs px-2 py-1 rounded-full bg-secondary text-secondary-foreground"
>
{tag}
</span>
))}
</div>
)}
<div className="space-y-2">
<Label htmlFor={`value-${item.id}`}></Label>
<Input
id={`value-${item.id}`}
value={item.value}
onChange={(e) => updateEnvItem(item.id, 'value', e.target.value)}
placeholder="输入环境变量的值"
type={
item.key.toLowerCase().includes('password') ||
item.key.toLowerCase().includes('secret') ||
item.key.toLowerCase().includes('key')
? 'password'
: 'text'
}
/>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => removeEnvItem(item.id)}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</CardHeader>
</Card>
))}
</div>
{envItems.length === 0 && (
<Card className="p-12">
<div className="text-center text-muted-foreground">
<p className="text-lg mb-4"></p>
<Button onClick={addEnvItem} variant="outline">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</Card>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { query } from '@/modules/query';
import { toast } from 'sonner';
import { QueryConfig } from '@kevisual/api/config';
import { QueryConfig, Config } from '@kevisual/api/config';
export const queryConfig = new QueryConfig({ query: query as any });
@@ -16,6 +16,10 @@ interface ConfigStore {
deleteConfig: (id: string) => Promise<void>;
detectConfig: () => Promise<void>;
onOpenKey: (key: string) => Promise<void>;
getEnv: () => Promise<void>;
updateEnv: (data: Config) => Promise<void>;
envData: Config;
setEnvData: (envData: Config) => void;
}
export const useConfigStore = create<ConfigStore>((set, get) => ({
@@ -75,4 +79,26 @@ export const useConfigStore = create<ConfigStore>((set, get) => ({
toast.error('获取配置失败');
}
},
getEnv: async () => {
const res = await queryConfig.getByKey('env.json');
if (res.code === 200) {
const data = res.data;
console.log(data);
set({ envData: data });
} else {
console.log(res);
toast.error('获取失败');
}
},
updateEnv: async (data: any) => {
const res = await queryConfig.updateConfig({ key: 'env.json', ...data });
if (res.code === 200) {
toast.success('更新成功');
} else {
console.log(res);
toast.error('更新失败');
}
},
envData: {},
setEnvData: (envData: any) => set({ envData }),
}));

292
src/app/user/page.tsx Normal file
View File

@@ -0,0 +1,292 @@
'use client';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useUserStore } from './store';
import { useLayoutStore } from '@/modules/layout/store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { LayoutMain } from '@/modules/layout';
import { Pencil, Key, User } from 'lucide-react';
import PandaPNG from '@/assets/panda.jpg';
const ProfileCard = () => {
const { me, getMe } = useLayoutStore();
const { setShowEdit, setShowChangePassword } = useUserStore();
useEffect(() => {
getMe();
}, [getMe]);
return (
<Card className="max-w-2xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="w-5 h-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Avatar */}
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-full overflow-hidden border-2 border-gray-200">
{me?.avatar ? (
<img
src={me.avatar}
alt="avatar"
className="w-full h-full object-cover"
/>
) : (
<img
src={PandaPNG.src}
alt="avatar"
className="w-full h-full object-cover"
/>
)}
</div>
<div>
<h3 className="text-lg font-semibold">{me?.username || '-'}</h3>
<p className="text-sm text-gray-500">{me?.description || '暂无描述'}</p>
</div>
</div>
{/* User Info Fields */}
<div className="space-y-4">
<div className="grid grid-cols-[120px_1fr] items-center gap-4">
<Label></Label>
<div className="text-gray-700">{me?.username || '-'}</div>
</div>
<div className="grid grid-cols-[120px_1fr] items-center gap-4">
<Label></Label>
<div className="flex items-center justify-between">
<span className="text-gray-700">{me?.nickname || '-'}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setShowEdit(true)}>
<Pencil className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-[120px_1fr] items-start gap-4">
<Label className="mt-2"></Label>
<div className="flex items-center justify-between flex-1">
<p className="text-gray-700 text-sm">{me?.description || '暂无描述'}</p>
<Button
variant="ghost"
size="sm"
onClick={() => setShowEdit(true)}>
<Pencil className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-[120px_1fr] items-center gap-4">
<Label>ID</Label>
<div className="text-gray-500 text-sm">{me?.id || '-'}</div>
</div>
<div className="grid grid-cols-[120px_1fr] items-center gap-4">
<Label></Label>
<Button
variant="outline"
size="sm"
onClick={() => setShowChangePassword(true)}>
<Key className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* Edit Profile Button */}
<div className="flex justify-end">
<Button onClick={() => setShowEdit(true)}>
<Pencil className="w-4 h-4 mr-1" />
</Button>
</div>
</CardContent>
</Card>
);
};
const EditProfileModal = () => {
const { showEdit, setShowEdit, setFormData, updateSelf, loading } = useUserStore();
const { me, getMe } = useLayoutStore();
const {
handleSubmit,
reset,
register,
} = useForm();
useEffect(() => {
if (showEdit) {
reset({
nickname: me?.nickname || '',
description: me?.description || '',
});
}
}, [me, showEdit, reset]);
const onSubmit = async (data: any) => {
const res = await updateSelf(data);
if (res) {
setShowEdit(false);
setFormData({});
await getMe();
}
};
return (
<Dialog open={showEdit} onOpenChange={(open) => {
setShowEdit(open);
if (!open) setFormData({});
}}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="p-4">
<form className="w-full flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2">
<Label></Label>
<Input
{...register('nickname')}
placeholder="请输入昵称"
/>
</div>
<div className="flex flex-col gap-2">
<Label></Label>
<Textarea
{...register('description')}
placeholder="请输入个人描述"
rows={4}
/>
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={() => setShowEdit(false)}
disabled={loading}>
</Button>
<Button type="submit" disabled={loading}>
{loading ? '保存中...' : '保存'}
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
);
};
const ChangePasswordModal = () => {
const { showChangePassword, setShowChangePassword, loading } = useUserStore();
const {
handleSubmit,
formState: { errors },
reset,
register,
} = useForm();
const onSubmit = async (data: any) => {
const { updateSelf } = useUserStore.getState();
const res = await updateSelf({
password: data.newPassword,
});
if (res) {
setShowChangePassword(false);
reset();
}
};
return (
<Dialog open={showChangePassword} onOpenChange={setShowChangePassword}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="p-4">
<form className="w-full flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2">
<Label></Label>
<Input
{...register('newPassword', {
required: '请输入新密码',
minLength: { value: 6, message: '密码长度至少6位' },
})}
type="password"
placeholder="请输入新密码"
className={errors.newPassword ? "border-red-500" : ""}
/>
{errors.newPassword && (
<span className="text-xs text-red-500">{errors.newPassword.message as string}</span>
)}
</div>
<div className="flex flex-col gap-2">
<Label></Label>
<Input
{...register('confirmPassword', {
required: '请确认新密码',
validate: (value, formValues) =>
value === formValues.newPassword || '两次输入的密码不一致',
})}
type="password"
placeholder="请再次输入新密码"
className={errors.confirmPassword ? "border-red-500" : ""}
/>
{errors.confirmPassword && (
<span className="text-xs text-red-500">{errors.confirmPassword.message as string}</span>
)}
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={() => setShowChangePassword(false)}
disabled={loading}>
</Button>
<Button type="submit" disabled={loading}>
{loading ? '保存中...' : '确认修改'}
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
);
};
export const UserProfile = () => {
return (
<div className="p-6 w-full h-full overflow-auto">
<ProfileCard />
<EditProfileModal />
<ChangePasswordModal />
</div>
);
};
export default () => {
return <LayoutMain title="个人信息"><UserProfile /></LayoutMain>;
}

View File

@@ -0,0 +1,57 @@
import { create } from 'zustand';
import { query, queryLogin } from '@/modules/query';
import { toast } from 'sonner';
type UserInfo = {
avatar: string;
description: string | null;
id: string;
needChangePassword: boolean;
nickname: string | null;
username: string;
}
type UserStore = {
showEdit: boolean;
setShowEdit: (showEdit: boolean) => void;
showNameEdit: boolean;
setShowNameEdit: (showNameEdit: boolean) => void;
showCheckUserExist: boolean;
setShowCheckUserExist: (showCheckUserExist: boolean) => void;
formData: any;
setFormData: (formData: any) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
updateSelf: (data: any) => Promise<any>;
showChangePassword: boolean;
setShowChangePassword: (showChangePassword: boolean) => void;
};
export const useUserStore = create<UserStore>((set, get) => {
return {
showEdit: false,
setShowEdit: (showEdit) => set({ showEdit }),
showNameEdit: false,
setShowNameEdit: (showNameEdit) => set({ showNameEdit }),
showCheckUserExist: false,
setShowCheckUserExist: (showCheckUserExist) => set({ showCheckUserExist }),
formData: {},
setFormData: (formData) => set({ formData }),
loading: false,
setLoading: (loading) => set({ loading }),
updateSelf: async (data) => {
const res = await query.post({
path: 'user',
key: 'updateSelf',
data,
});
if (res.code === 200) {
toast.success('Success');
set({ formData: res.data });
return res.data;
} else {
toast.error(res.message || 'Request failed');
}
},
showChangePassword: false,
setShowChangePassword: (showChangePassword) => set({ showChangePassword }),
};
});

View File

@@ -0,0 +1,90 @@
import { query, queryLogin } from '@/modules/query';
import { basename } from '@/modules/basename';
import { toast as message } from 'sonner';
import { create } from 'zustand';
// 如果自己是在iframe中登录需要调用这个方法
export const postLoginInIframe = (token: string) => {
console.log('window.parent !== window', window.parent !== window);
if (window.parent === window) {
return;
}
// 获取父窗口的来源
const parentOrigin = window.location.ancestorOrigins ? window.location.ancestorOrigins[0] : document.referrer;
// 检查父窗口的来源是否合法
const allowedOrigins = ['http://localhost', /^https?:\/\/(.+\.)?on-ai\.ai$/, /^https?:\/\/(.+\.)?xiongxiao\.me$/];
let targetOrigin: string | null = null;
// 根据来源动态选择 targetOrigin
if (allowedOrigins.some((origin) => (typeof origin === 'string' ? parentOrigin.includes(origin) : origin.test(parentOrigin)))) {
targetOrigin = parentOrigin; // 使用合法来源作为 targetOrigin
}
// 如果找到合法的 targetOrigin则发送消息
if (targetOrigin) {
const message = { type: 'login-from-iframe', data: { token } };
parent.postMessage(message, targetOrigin);
} else {
console.warn('Parent origin is not allowed:', parentOrigin);
}
};
type LoginStore = {
loading: boolean;
setLoading: (loading: boolean) => void;
formData: any;
setFormData: (formData: any) => void;
login: () => Promise<void>;
register: () => Promise<void>;
isLogin: boolean;
setIsLogin: (isLogin: boolean) => void;
};
export const useLoginStore = create<LoginStore>((set, get) => {
return {
loading: false,
setLoading: (loading) => set({ loading }),
formData: {},
setFormData: (formData) => set({ formData }),
login: async () => {
const { formData } = get();
const { username, password } = formData;
if (!username || !password) {
message.error('Please input username and password');
return;
}
set({ loading: true });
const res = await queryLogin.login({ username, password });
if (res.code === 200) {
message.success('Success');
set({ isLogin: true });
await new Promise((resolve) => setTimeout(resolve, 1000));
if (window.parent !== window) {
postLoginInIframe(res.data?.accessToken || '');
await new Promise((resolve) => setTimeout(resolve, 3000));
}
const search = new URLSearchParams(window.location.search);
const redirect = search.get('redirect');
if (redirect) {
window.location.href = redirect;
} else {
window.location.href = basename ? basename + '/' : '/';
}
} else {
message.error(res.message || 'Request failed');
}
},
register: async () => {
set({ loading: true });
const res = await query.post({ path: 'user', key: 'register' });
if (res.code === 200) {
message.success('Success');
// 跳到某一个页面
} else {
message.error(res.message || 'Request failed');
}
},
isLogin: false,
setIsLogin: (isLogin) => set({ isLogin }),
};
});

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,310 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon
data-slot="combobox-trigger-icon"
className="text-muted-foreground pointer-events-none size-4"
/>
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none",
className
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
data-slot="combobox-item-indicator"
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn(
"text-muted-foreground px-2 py-1.5 text-xs pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
className
)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1.5",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
children,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}

View File

@@ -0,0 +1,170 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -47,9 +47,10 @@ export const usePlatformStore = create<PlatfromStore>((set) => {
type Me = {
id?: string;
username?: string;
nickname?: string | null;
needChangePassword?: boolean;
role?: string;
description?: string;
description?: string | null;
type?: 'user' | 'org';
orgs?: string[];
avatar?: string;