This commit is contained in:
2026-01-23 02:35:52 +08:00
parent 9849f93b1e
commit 2db3868fcf
39 changed files with 3381 additions and 164 deletions

View File

@@ -1 +1,2 @@
NODE_ENV=
NODE_ENV=
API_URL=https://kevisual.xiongxiao.me

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.env
!.env*example
dist
.next
out

View File

@@ -10,6 +10,10 @@ const nextConfig: NextConfig = {
distDir: 'dist',
basePath: basePath,
trailingSlash: true,
transpilePackages: ['@kevisual/api'],
images: {
unoptimized: true,
},
};
export default nextConfig;

View File

@@ -1,17 +1,20 @@
{
"name": "@kevisual/next-simple-template",
"name": "@kevisual/center",
"version": "0.1.0",
"basename": "/root/next-simple-template",
"basename": "/root/center",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"pub": "ev deploy ./dist -k next-simple-template -v 0.1.0 -u -y y",
"pub": "ev deploy ./dist -k center -v 0.1.0 -u -y y",
"ui": "pnpm dlx shadcn@latest add "
},
"dependencies": {
"@kevisual/query": "^0.0.35",
"@kevisual/router": "^0.0.53",
"@ant-design/icons": "^6.1.0",
"@kevisual/api": "^0.0.26",
"@kevisual/cache": "^0.0.5",
"@kevisual/query": "^0.0.38",
"@kevisual/router": "^0.0.60",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -20,25 +23,33 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"antd": "^6.2.0",
"antd": "^6.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^4.1.0",
"dayjs": "^1.11.19",
"dotenv": "^17.2.3",
"es-toolkit": "^1.43.0",
"es-toolkit": "^1.44.0",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.562.0",
"next": "16.1.2",
"marked": "^17.0.1",
"next": "16.1.4",
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"valtio": "^2.3.0",
"zustand": "^5.0.10"
},
"devDependencies": {
"@kevisual/types": "^0.0.12",
"@tailwindcss/postcss": "^4",
"@types/node": "^25",
"@types/react": "^19",

388
pnpm-lock.yaml generated
View File

@@ -8,12 +8,21 @@ importers:
.:
dependencies:
'@ant-design/icons':
specifier: ^6.1.0
version: 6.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@kevisual/api':
specifier: ^0.0.26
version: 0.0.26
'@kevisual/cache':
specifier: ^0.0.5
version: 0.0.5
'@kevisual/query':
specifier: ^0.0.35
version: 0.0.35
specifier: ^0.0.38
version: 0.0.38
'@kevisual/router':
specifier: ^0.0.53
version: 0.0.53
specifier: ^0.0.60
version: 0.0.60
'@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.3(react@19.2.3))(react@19.2.3)
@@ -38,6 +47,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-switch':
specifier: ^1.2.6
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -45,8 +57,8 @@ importers:
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
antd:
specifier: ^6.2.0
version: 6.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
specifier: ^6.2.1
version: 6.2.1(date-fns@4.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -56,30 +68,45 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
copy-to-clipboard:
specifier: ^3.3.3
version: 3.3.3
date-fns:
specifier: ^4.1.0
version: 4.1.0
dayjs:
specifier: ^1.11.19
version: 1.11.19
dotenv:
specifier: ^17.2.3
version: 17.2.3
es-toolkit:
specifier: ^1.43.0
version: 1.43.0
specifier: ^1.44.0
version: 1.44.0
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
jotai:
specifier: ^2.16.1
version: 2.16.1(@types/react@19.2.7)(react@19.2.3)
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@19.2.3)
marked:
specifier: ^17.0.1
version: 17.0.1
next:
specifier: 16.1.1
version: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
specifier: 16.1.4
version: 16.1.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react:
specifier: 19.2.3
version: 19.2.3
react-day-picker:
specifier: ^9.13.0
version: 9.13.0(react@19.2.3)
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
react-hook-form:
specifier: ^7.71.1
version: 7.71.1(react@19.2.3)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -93,6 +120,9 @@ importers:
specifier: ^5.0.10
version: 5.0.10(@types/react@19.2.7)(react@19.2.3)
devDependencies:
'@kevisual/types':
specifier: ^0.0.12
version: 0.0.12
'@tailwindcss/postcss':
specifier: ^4
version: 4.1.18
@@ -130,8 +160,8 @@ packages:
react: '>=18'
react-dom: '>=18'
'@ant-design/cssinjs@2.0.2':
resolution: {integrity: sha512-7KDVIigtqlamOLtJ0hbjECX/sDGDaJXsM/KHala8I/1E4lpl9RAO585kbVvh/k1rIrFAV6JeGkXmdWyYj9XvuA==}
'@ant-design/cssinjs@2.0.3':
resolution: {integrity: sha512-HAo8SZ3a6G8v6jT0suCz1270na6EA3obeJWM4uzRijBhdwdoMAXWK2f4WWkwB28yUufsfk3CAhN1coGPQq4kNQ==}
peerDependencies:
react: '>=16.0.0'
react-dom: '>=16.0.0'
@@ -160,6 +190,9 @@ packages:
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@emnapi/runtime@1.8.1':
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
@@ -337,59 +370,74 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kevisual/query@0.0.35':
resolution: {integrity: sha512-80dyy2LMCmEC72g+X4QWUKlZErhawQPgnGSBNR4yhrBcFgHIJQ14LR1Z+bS5S1I7db+1PDNpaxBTjIaoYoXunw==}
'@kevisual/api@0.0.26':
resolution: {integrity: sha512-u40PNMeVoWfUrUXWSEOc6/aacmt+flq7gE6kdmJJpgc9rfn8I6mXWlp3z0FPrAuqJopKAraiU9To+U72TL8U9g==}
'@kevisual/router@0.0.53':
resolution: {integrity: sha512-Bw9xYVWyxRhd230nF1ac7cyvzWDYKI/3V+Fr1Ew1Bfr0Ey8KuWb1MgPPopHkRHCCcUcysLtWXfu/JRiTAoBmGA==}
'@kevisual/cache@0.0.5':
resolution: {integrity: sha512-fgtUYGUUq/DY0KFV4CkWszNqvQUaA8XvMTUjoR9ZXRpau5IIDolD/Wen2TFsZ7G3Rfy+lef5dnaiZVDkZwdVKg==}
'@next/env@16.1.1':
resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==}
'@kevisual/js-filter@0.0.5':
resolution: {integrity: sha512-+S+Sf3K/aP6XtZI2s7TgKOr35UuvUvtpJ9YDW30a+mY0/N8gRuzyKhieBzQN7Ykayzz70uoMavBXut2rUlLgzw==}
'@next/swc-darwin-arm64@16.1.1':
resolution: {integrity: sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==}
'@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/router@0.0.60':
resolution: {integrity: sha512-2v/ZzUstsaq+Uqo+tZX9ys5E+/2erPggCtljv9jTb3NA88ZdHsYUAsd5wUFvLtf9QucpJCzyWEt+InDV/98FKw==}
'@kevisual/types@0.0.12':
resolution: {integrity: sha512-zJXH2dosir3jVrQ6QG4i0+iLQeT9gJ3H+cKXs8ReWboxBSYzUZO78XssVeVrFPsJ33iaAqo4q3DWbSS1dWGn7Q==}
'@next/env@16.1.4':
resolution: {integrity: sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A==}
'@next/swc-darwin-arm64@16.1.4':
resolution: {integrity: sha512-T8atLKuvk13XQUdVLCv1ZzMPgLPW0+DWWbHSQXs0/3TjPrKNxTmUIhOEaoEyl3Z82k8h/gEtqyuoZGv6+Ugawg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.1.1':
resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==}
'@next/swc-darwin-x64@16.1.4':
resolution: {integrity: sha512-AKC/qVjUGUQDSPI6gESTx0xOnOPQ5gttogNS3o6bA83yiaSZJek0Am5yXy82F1KcZCx3DdOwdGPZpQCluonuxg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.1.1':
resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==}
'@next/swc-linux-arm64-gnu@16.1.4':
resolution: {integrity: sha512-POQ65+pnYOkZNdngWfMEt7r53bzWiKkVNbjpmCt1Zb3V6lxJNXSsjwRuTQ8P/kguxDC8LRkqaL3vvsFrce4dMQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@16.1.1':
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
'@next/swc-linux-arm64-musl@16.1.4':
resolution: {integrity: sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@16.1.1':
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
'@next/swc-linux-x64-gnu@16.1.4':
resolution: {integrity: sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@16.1.1':
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
'@next/swc-linux-x64-musl@16.1.4':
resolution: {integrity: sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@16.1.1':
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
'@next/swc-win32-arm64-msvc@16.1.4':
resolution: {integrity: sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.1.1':
resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==}
'@next/swc-win32-x64-msvc@16.1.4':
resolution: {integrity: sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -697,6 +745,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-switch@1.2.6':
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies:
@@ -983,8 +1044,8 @@ packages:
react: '>=16.9.0'
react-dom: '>=16.9.0'
'@rc-component/resize-observer@1.0.1':
resolution: {integrity: sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag==}
'@rc-component/resize-observer@1.1.1':
resolution: {integrity: sha512-NfXXMmiR+SmUuKE1NwJESzEUYUFWIDUn2uXpxCTOLwiRUUakd62DRNFjRJArgzyFW8S5rsL4aX5XlyIXyC/vRA==}
peerDependencies:
react: '>=16.9.0'
react-dom: '>=16.9.0'
@@ -1068,8 +1129,8 @@ packages:
react: '*'
react-dom: '*'
'@rc-component/trigger@3.8.2':
resolution: {integrity: sha512-I6idYAk8YY3Ly6v5hB7ONqxfdTYTcVNUmV1ZjtSsGH6N/k5tss9+OAtusr+7zdlIcD7TwnlsoB5etfB14ORtMw==}
'@rc-component/trigger@3.9.0':
resolution: {integrity: sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg==}
engines: {node: '>=8.x'}
peerDependencies:
react: '>=18.0.0'
@@ -1196,8 +1257,8 @@ packages:
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
antd@6.2.0:
resolution: {integrity: sha512-fwETatwHYExjfzKcV41fBtgPo4kp+g+9gp5YOSSGxwnJHljps8TbXef8WP7ZnaOn5dkcA9xIC0TyUecIybBG7w==}
antd@6.2.1:
resolution: {integrity: sha512-ycw/XX7So4MdrwYKGfvZJdkGiCYUOSTebAIi+ejE95WJ138b11oy/iJg7iH0qydaD/B5sFd7Tz8XfPBuW7CRmw==}
peerDependencies:
react: '>=18.0.0'
react-dom: '>=18.0.0'
@@ -1232,9 +1293,18 @@ packages:
compute-scroll-into-view@3.1.1:
resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
copy-to-clipboard@3.3.3:
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dayjs@1.11.19:
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
@@ -1253,8 +1323,11 @@ packages:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
es-toolkit@1.43.0:
resolution: {integrity: sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==}
es-toolkit@1.44.0:
resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
@@ -1263,6 +1336,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
hono@4.11.5:
resolution: {integrity: sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==}
engines: {node: '>=16.9.0'}
idb-keyval@6.2.2:
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
@@ -1273,24 +1350,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jotai@2.16.1:
resolution: {integrity: sha512-vrHcAbo3P7Br37C8Bv6JshMtlKMPqqmx0DDREtTjT4nf3QChDrYdbH+4ik/9V0cXA57dK28RkJ5dctYvavcIlg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@babel/core': '>=7.0.0'
'@babel/template': '>=7.0.0'
'@types/react': '>=17.0.0'
react: '>=17.0.0'
peerDependenciesMeta:
'@babel/core':
optional: true
'@babel/template':
optional: true
'@types/react':
optional: true
react:
optional: true
json2mq@0.2.0:
resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==}
@@ -1364,6 +1423,10 @@ packages:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
lru-cache@11.2.4:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
lucide-react@0.562.0:
resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==}
peerDependencies:
@@ -1372,13 +1435,23 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
next@16.1.1:
resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==}
nanoid@5.1.6:
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
engines: {node: ^18 || >=20}
hasBin: true
next@16.1.4:
resolution: {integrity: sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -1412,11 +1485,23 @@ packages:
proxy-compare@3.0.1:
resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==}
react-day-picker@9.13.0:
resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8.0'
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
react: ^19.2.3
react-hook-form@7.71.1:
resolution: {integrity: sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
@@ -1512,6 +1597,9 @@ packages:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
toggle-selection@1.0.6:
resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1586,13 +1674,13 @@ snapshots:
'@ant-design/cssinjs-utils@2.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@ant-design/cssinjs': 2.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@ant-design/cssinjs': 2.0.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@babel/runtime': 7.28.6
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@ant-design/cssinjs@2.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
'@ant-design/cssinjs@2.0.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.28.6
'@emotion/hash': 0.8.0
@@ -1628,6 +1716,8 @@ snapshots:
'@babel/runtime@7.28.6': {}
'@date-fns/tz@1.4.1': {}
'@emnapi/runtime@1.8.1':
dependencies:
tslib: 2.8.1
@@ -1770,36 +1860,60 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@kevisual/query@0.0.35':
'@kevisual/api@0.0.26':
dependencies:
'@kevisual/js-filter': 0.0.5
'@kevisual/load': 0.0.6
es-toolkit: 1.44.0
eventemitter3: 5.0.4
nanoid: 5.1.6
'@kevisual/cache@0.0.5':
dependencies:
idb-keyval: 6.2.2
lru-cache: 11.2.4
nanoid: 5.1.6
'@kevisual/js-filter@0.0.5': {}
'@kevisual/load@0.0.6':
dependencies:
eventemitter3: 5.0.4
'@kevisual/query@0.0.38':
dependencies:
tslib: 2.8.1
'@kevisual/router@0.0.53': {}
'@kevisual/router@0.0.60':
dependencies:
hono: 4.11.5
'@next/env@16.1.1': {}
'@kevisual/types@0.0.12': {}
'@next/swc-darwin-arm64@16.1.1':
'@next/env@16.1.4': {}
'@next/swc-darwin-arm64@16.1.4':
optional: true
'@next/swc-darwin-x64@16.1.1':
'@next/swc-darwin-x64@16.1.4':
optional: true
'@next/swc-linux-arm64-gnu@16.1.1':
'@next/swc-linux-arm64-gnu@16.1.4':
optional: true
'@next/swc-linux-arm64-musl@16.1.1':
'@next/swc-linux-arm64-musl@16.1.4':
optional: true
'@next/swc-linux-x64-gnu@16.1.1':
'@next/swc-linux-x64-gnu@16.1.4':
optional: true
'@next/swc-linux-x64-musl@16.1.1':
'@next/swc-linux-x64-musl@16.1.4':
optional: true
'@next/swc-win32-arm64-msvc@16.1.1':
'@next/swc-win32-arm64-msvc@16.1.4':
optional: true
'@next/swc-win32-x64-msvc@16.1.1':
'@next/swc-win32-x64-msvc@16.1.4':
optional: true
'@radix-ui/number@1.1.1': {}
@@ -2127,6 +2241,21 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
'@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.7)(react@19.2.3)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.7)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -2291,7 +2420,7 @@ snapshots:
'@rc-component/dropdown@1.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@rc-component/trigger': 3.8.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
react: 19.2.3
@@ -2334,7 +2463,7 @@ snapshots:
'@rc-component/input': 1.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/menu': 1.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/textarea': 1.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.8.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
react: 19.2.3
@@ -2344,7 +2473,7 @@ snapshots:
dependencies:
'@rc-component/motion': 1.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/overflow': 1.0.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.8.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
react: 19.2.3
@@ -2378,7 +2507,7 @@ snapshots:
'@rc-component/overflow@1.0.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.28.6
'@rc-component/resize-observer': 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
react: 19.2.3
@@ -2391,16 +2520,17 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@rc-component/picker@1.9.0(dayjs@1.11.19)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
'@rc-component/picker@1.9.0(date-fns@4.1.0)(dayjs@1.11.19)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@rc-component/overflow': 1.0.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.8.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
date-fns: 4.1.0
dayjs: 1.11.19
'@rc-component/portal@2.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
@@ -2430,7 +2560,7 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@rc-component/resize-observer@1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
'@rc-component/resize-observer@1.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
@@ -2448,7 +2578,7 @@ snapshots:
'@rc-component/select@1.5.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@rc-component/overflow': 1.0.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.8.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/virtual-list': 1.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
@@ -2479,7 +2609,7 @@ snapshots:
'@rc-component/table@1.9.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@rc-component/context': 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/virtual-list': 1.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
@@ -2491,7 +2621,7 @@ snapshots:
'@rc-component/dropdown': 1.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/menu': 1.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/motion': 1.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
react: 19.2.3
@@ -2500,7 +2630,7 @@ snapshots:
'@rc-component/textarea@1.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@rc-component/input': 1.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
react: 19.2.3
@@ -2508,7 +2638,7 @@ snapshots:
'@rc-component/tooltip@1.4.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@rc-component/trigger': 3.8.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
react: 19.2.3
@@ -2517,7 +2647,7 @@ snapshots:
'@rc-component/tour@2.3.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@rc-component/portal': 2.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.8.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
react: 19.2.3
@@ -2541,11 +2671,11 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@rc-component/trigger@3.8.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
'@rc-component/trigger@3.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@rc-component/motion': 1.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/portal': 2.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
react: 19.2.3
@@ -2568,7 +2698,7 @@ snapshots:
'@rc-component/virtual-list@1.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.28.6
'@rc-component/resize-observer': 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
react: 19.2.3
@@ -2659,10 +2789,10 @@ snapshots:
dependencies:
csstype: 3.2.3
antd@6.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
antd@6.2.1(date-fns@4.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/cssinjs': 2.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@ant-design/cssinjs': 2.0.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@ant-design/cssinjs-utils': 2.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@ant-design/fast-color': 3.0.0
'@ant-design/icons': 6.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -2685,11 +2815,11 @@ snapshots:
'@rc-component/mutate-observer': 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/notification': 1.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/pagination': 1.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/picker': 1.9.0(dayjs@1.11.19)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/picker': 1.9.0(date-fns@4.1.0)(dayjs@1.11.19)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/progress': 1.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/qrcode': 1.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/rate': 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/resize-observer': 1.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/segmented': 1.3.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/select': 1.5.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/slider': 1.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -2702,7 +2832,7 @@ snapshots:
'@rc-component/tour': 2.3.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/tree': 1.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/tree-select': 1.6.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.8.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/trigger': 3.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/upload': 1.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
clsx: 2.1.1
@@ -2746,8 +2876,16 @@ snapshots:
compute-scroll-into-view@3.1.1: {}
copy-to-clipboard@3.3.3:
dependencies:
toggle-selection: 1.0.6
csstype@3.2.3: {}
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
dayjs@1.11.19: {}
detect-libc@2.1.2: {}
@@ -2761,23 +2899,22 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
es-toolkit@1.43.0: {}
es-toolkit@1.44.0: {}
eventemitter3@5.0.4: {}
get-nonce@1.0.1: {}
graceful-fs@4.2.11: {}
hono@4.11.5: {}
idb-keyval@6.2.2: {}
is-mobile@5.0.0: {}
jiti@2.6.1: {}
jotai@2.16.1(@types/react@19.2.7)(react@19.2.3):
optionalDependencies:
'@types/react': 19.2.7
react: 19.2.3
json2mq@0.2.0:
dependencies:
string-convert: 0.2.1
@@ -2831,6 +2968,8 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
lru-cache@11.2.4: {}
lucide-react@0.562.0(react@19.2.3):
dependencies:
react: 19.2.3
@@ -2839,11 +2978,15 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@17.0.1: {}
nanoid@3.3.11: {}
next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
nanoid@5.1.6: {}
next@16.1.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@next/env': 16.1.1
'@next/env': 16.1.4
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.9.11
caniuse-lite: 1.0.30001762
@@ -2852,14 +2995,14 @@ snapshots:
react-dom: 19.2.3(react@19.2.3)
styled-jsx: 5.1.6(react@19.2.3)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.1
'@next/swc-darwin-x64': 16.1.1
'@next/swc-linux-arm64-gnu': 16.1.1
'@next/swc-linux-arm64-musl': 16.1.1
'@next/swc-linux-x64-gnu': 16.1.1
'@next/swc-linux-x64-musl': 16.1.1
'@next/swc-win32-arm64-msvc': 16.1.1
'@next/swc-win32-x64-msvc': 16.1.1
'@next/swc-darwin-arm64': 16.1.4
'@next/swc-darwin-x64': 16.1.4
'@next/swc-linux-arm64-gnu': 16.1.4
'@next/swc-linux-arm64-musl': 16.1.4
'@next/swc-linux-x64-gnu': 16.1.4
'@next/swc-linux-x64-musl': 16.1.4
'@next/swc-win32-arm64-msvc': 16.1.4
'@next/swc-win32-x64-msvc': 16.1.4
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -2881,11 +3024,22 @@ snapshots:
proxy-compare@3.0.1: {}
react-day-picker@9.13.0(react@19.2.3):
dependencies:
'@date-fns/tz': 1.4.1
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
react: 19.2.3
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3
scheduler: 0.27.0
react-hook-form@7.71.1(react@19.2.3):
dependencies:
react: 19.2.3
react-is@18.3.1: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3):
@@ -2982,6 +3136,8 @@ snapshots:
throttle-debounce@5.0.2: {}
toggle-selection@1.0.6: {}
tslib@2.8.1: {}
tw-animate-css@1.4.0: {}

BIN
public/panda.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1,9 +0,0 @@
export default function AboutPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
About Light Code
</main>
</div>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useLayoutStore } from '@/modules/layout/store';
import { useShallow } from 'zustand/shallow';
import { toast } from 'sonner';
import { Folder } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { openLink } from '@/modules/basename';
type Props = {
pathname?: string;
};
export const AIEditorLink = (props: Props) => {
const layoutUser = useLayoutStore(
useShallow((state) => ({
user: state.me?.username || '',
})),
);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
if (!layoutUser.user) {
toast.error('请先登录');
}
if (!window) {
return;
}
let folder = `${layoutUser.user}/resources/${props.pathname}`;
if (folder.endsWith('/')) {
folder = folder.slice(0, -1);
}
let baseUri = location.origin;
const openUrl = `${baseUri}/root/ai-pages/ai-editor/?folder=${folder}/`;
openLink(openUrl, '_blank');
}}>
<Folder className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
);
};

378
src/app/apps/app/page.tsx Normal file
View File

@@ -0,0 +1,378 @@
'use client';
import { useAppVersionStore } from '../store';
import { useShallow } from 'zustand/react/shallow';
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { Plus, ChevronLeft, Upload, File, Trash2, ExternalLink } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { isObjectNull } from '@/modules/is-null';
import { FileUpload } from '../modules/FileUpload';
import clsx from 'clsx';
import { toast as message } from 'sonner';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Controller, useForm } from 'react-hook-form';
import { pick } from 'es-toolkit';
import { useAppDeleteModalStore, AppDeleteModal } from '../modules/AppDeleteModal';
import { AIEditorLink } from './AIEditorLink';
import { openLink } from '@/modules/basename';
import { LayoutMain } from '@/modules/layout';
const FormModal = () => {
const { control, handleSubmit, reset } = useForm();
const containerStore = useAppVersionStore(
useShallow((state) => {
return {
showEdit: state.showEdit,
setShowEdit: state.setShowEdit,
formData: state.formData,
updateData: state.updateData,
};
}),
);
useEffect(() => {
const open = containerStore.showEdit;
if (open) {
const isNull = isObjectNull(containerStore.formData);
if (isNull) {
reset({});
} else {
reset(containerStore.formData);
}
}
}, [containerStore.showEdit]);
const onFinish = async (values: any) => {
const pickValues = pick(values, ['id', 'key', 'version']);
containerStore.updateData(pickValues);
};
const onClose = () => {
containerStore.setShowEdit(false);
reset();
};
const isEdit = containerStore.formData.id;
return (
<Dialog open={containerStore.showEdit} onOpenChange={(open) => containerStore.setShowEdit(open)}>
<DialogContent className='w-[800px]'>
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit' : 'Add'}</DialogTitle>
</DialogHeader>
<form className='flex flex-col gap-6 py-4' onSubmit={handleSubmit(onFinish)}>
<div className='grid gap-2'>
<Label htmlFor='key'>key</Label>
<Controller
name='key'
control={control}
defaultValue=''
render={({ field }) => <Input id='key' {...field} disabled />}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='version'>version</Label>
<Controller
name='version'
control={control}
defaultValue=''
render={({ field }) => <Input id='version' {...field} />}
/>
</div>
<div className='flex gap-2'>
<Button type='submit'>Submit</Button>
<Button variant='outline' onClick={onClose}>
Cancel
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
const getAppKey = () => {
const [appKey, setAppKey] = useState('');
useLayoutEffect(() => {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
const appKey = url.searchParams.get('appKey');
setAppKey(appKey || '');
}, []);
return appKey || '';
}
export const AppVersionList = () => {
const appKey = getAppKey();
const versionStore = useAppVersionStore(
useShallow((state) => {
return {
list: state.list,
getList: state.getList,
key: state.key,
setKey: state.setKey,
setShowEdit: state.setShowEdit,
formData: state.formData,
setFormData: state.setFormData,
deleteData: state.deleteData,
publishVersion: state.publishVersion,
app: state.app,
};
}),
);
const appDeleteModalStore = useAppDeleteModalStore(
useShallow((state) => {
return {
onClickDelete: state.onClickDelete,
};
}),
);
const [isUpload, setIsUpload] = useState(false);
useEffect(() => {
// fetch app version list
if (appKey) {
versionStore.setKey(appKey);
versionStore.getList();
}
}, [appKey]);
const appVersion = useMemo(() => {
return versionStore.app?.version || '';
}, [versionStore.app?.version]);
if (!appKey) {
return <div>App Key is required</div>;
}
return (
<div className='w-full h-full flex bg-slate-100'>
<div className='p-2 bg-white'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
versionStore.setFormData({ key: appKey });
versionStore.setShowEdit(true);
}}>
<Plus className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
<div className='grow h-full relative'>
<div className='absolute top-2 left-4'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
// navigate('/app/edit/list');
history.back();
}}>
<ChevronLeft className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
<div className='w-full h-full p-4 pt-12'>
<div className='w-full h-full rounded-lg bg-white'>
<div className='flex gap-2 flex-wrap p-4'>
{versionStore.list.map((item, index) => {
const isPublish = item.version === appVersion;
const color = isPublish ? 'bg-green-500' : '';
const isRunning = item.status === 'running';
return (
<div className='w-[300px] bg-white rounded-lg border border-slate-200 shadow-sm p-4' key={index}>
<div className={'flex items-center justify-between'}>
<span className='font-medium'>{item.version}</span>
<Tooltip>
<TooltipTrigger asChild>
<div className={clsx('rounded-full w-4 h-4', color)}></div>
</TooltipTrigger>
<TooltipContent>{isPublish ? 'published' : ''}</TooltipContent>
</Tooltip>
</div>
<div className='mt-4 flex gap-1'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={(e) => {
appDeleteModalStore.onClickDelete('app-version', item);
e.stopPropagation();
}}>
<Trash2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
versionStore.publishVersion({ id: item.id });
}}>
<Upload className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>使</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
if (isRunning) {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const link = new URL(`/test/${item.id}`, origin);
openLink(link.toString(), '_blank');
} else {
message.error('The app is not running');
}
}}>
<ExternalLink className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>To Test App</TooltipContent>
</Tooltip>
<AIEditorLink pathname={item.key + '/' + item.version} />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
versionStore.setFormData(item);
setIsUpload(true);
}}>
<File className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
<div className='shark h-full'>
{isUpload && (
<div className='bg-white p-2 w-[600px] h-full flex flex-col'>
<div className='header flex items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
setIsUpload(false);
}}>
<ChevronLeft className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<div className='font-bold'>{versionStore.key}</div>
</div>
<AppVersionFile />
</div>
)}
</div>
<FormModal />
<AppDeleteModal />
</div>
);
};
export const AppVersionFile = () => {
const versionStore = useAppVersionStore(
useShallow((state) => {
return {
formData: state.formData,
detectVersionList: state.detectVersionList,
};
}),
);
const versionFiles = useMemo(() => {
if (!versionStore.formData?.data) return [];
const files = versionStore.formData.data.files || [];
return files as any[];
}, [versionStore.formData]);
const onDetect = useCallback(async () => {
console.log('formData', versionStore.formData);
if (!versionStore.formData.key || !versionStore.formData.version) {
message.error('请先选择应用和版本');
return;
}
const res = await versionStore.detectVersionList({
appKey: versionStore.formData.key,
version: versionStore.formData.version,
});
console.log('res', res);
if (res.code === 200) {
message.success('检测实际文件成功');
} else {
message.error(res.message || 'Detect failed');
}
}, [versionStore.formData]);
return (
<>
<div>version: {versionStore.formData.version}</div>
<div className='border border-gray-200 rounded-md my-2 grow overflow-hidden'>
<div className='flex gap-2 items-center border-b border-b-gray-200 py-2 px-2'>
Files
<FileUpload />
<Tooltip>
<TooltipTrigger asChild>
<Button variant='outline' size='sm' onClick={onDetect}>
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
<div
className='mt-2 '
style={{
height: 'calc(100% - 40px)',
}}>
<div className='h-full overflow-auto mb-4 pb-8 scrollbar'>
{versionFiles.map((file, index) => {
const prefix = versionStore.formData.key + '/' + versionStore.formData.version + '/';
const _path = file.path || '';
const path = _path.replace(prefix, '');
return (
<div className='flex gap-2 px-4 py-2 border-b border-b-gray-200' key={index}>
{/* <div className='w-[100px] truncate'>{file.name}</div> */}
<div>
<File className='h-4 w-4' />
</div>
<div>{path}</div>
</div>
);
})}
</div>
</div>
</div>
</>
);
};
export default () => {
return <LayoutMain>
<AppVersionList />
</LayoutMain>
};

11
src/app/apps/constants.ts Normal file
View File

@@ -0,0 +1,11 @@
export const iText = {
share: {
title: '共享设置',
tips: `共享设置
1. 设置公共可以直接访问
2. 设置受保护需要登录后访问
3. 设置私有只有自己可以访问。\n
受保护可以设置密码,设置访问的用户名。切换共享状态后,需要重新设置密码和用户名。`,
},
};

View File

@@ -0,0 +1,7 @@
'use client';
import { LayoutMain } from '@/modules/layout';
export const Main = () => {
return <LayoutMain title='User Apps' />;
};

View File

@@ -0,0 +1,86 @@
'use client';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { create } from 'zustand';
import { useAppVersionStore, useUserAppStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
type AppDeleteModalStore = {
open: boolean;
setOpen: (open: boolean) => void;
app: any;
setApp: (app: any) => void;
type: 'user-app' | 'app-version';
setType: (type: 'user-app' | 'app-version') => void;
onClickDelete: (type: 'user-app' | 'app-version', data: any) => void;
};
export const useAppDeleteModalStore = create<AppDeleteModalStore>((set) => ({
open: false,
setOpen: (open) => set({ open }),
app: null,
setApp: (app) => set({ app }),
type: 'user-app',
setType: (type) => set({ type }),
onClickDelete: (type, data) => {
set({ open: true, type, app: data });
},
}));
export const AppDeleteModal = () => {
const { open, setOpen, app, type } = useAppDeleteModalStore();
const userAppStore = useUserAppStore(
useShallow((state) => {
return {
deleteData: state.deleteData,
};
}),
);
const appVersionStore = useAppVersionStore(
useShallow((state) => {
return {
deleteData: state.deleteData,
};
}),
);
const onClose = () => {
setOpen(false);
};
const onDelete = (deleteFile = false) => {
if (type === 'user-app') {
userAppStore.deleteData(app.id, deleteFile);
} else {
appVersionStore.deleteData(app.id, deleteFile);
}
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Tips</DialogTitle>
</DialogHeader>
<div className='w-[400px]'>
<p className='text-sm text-gray-500'>Delete App Introduce</p>
</div>
<DialogFooter>
<Button variant='default' onClick={() => onDelete()}>
Delete
</Button>
<Button variant='outline' onClick={onClose}>
Cancel
</Button>
<Button
variant='destructive'
onClick={() => {
onDelete(true);
}}>
Delete and remove file
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import { Calendar as CalendarIcon } from "lucide-react"
import dayjs, { type Dayjs } from "dayjs"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
interface DatePickerProps {
className?: string
value?: string | Dayjs
onChange?: (date: Dayjs) => void
}
export function DatePicker({ className, value, onChange }: DatePickerProps) {
const [date, setDate] = React.useState<Date | undefined>(
value ? new Date(typeof value === 'string' ? value : value.toISOString()) : undefined
)
React.useEffect(() => {
if (value) {
const dateValue = typeof value === 'string' ? value : value.toISOString()
setDate(new Date(dateValue))
}
}, [value])
const handleSelect = (selectedDate: Date | undefined) => {
setDate(selectedDate)
if (selectedDate && onChange) {
onChange(dayjs(selectedDate))
}
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal",
!date && "text-muted-foreground",
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? dayjs(date).format("YYYY-MM-DD") : <span></span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleSelect}
/>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,111 @@
'use client';
import { Button } from '@/components/ui/button';
import { useCallback, useRef } from 'react';
import { useAppVersionStore } from '../store';
import { useShallow } from 'zustand/react/shallow';
import { toast as message } from 'sonner';
export type FileType = {
name: string;
size: number;
lastModified: number;
webkitRelativePath: string; // 包含name
};
export const FileUpload = () => {
const ref = useRef<HTMLInputElement | null>(null);
const appVersionStore = useAppVersionStore(
useShallow((state) => {
return {
formData: state.formData,
setFormData: state.setFormData,
updateByFromData: state.updateByFromData,
};
}),
);
const onChange = useCallback(
async (e: any) => {
console.log(e.target.files);
// webkitRelativePath
let files = Array.from(e.target.files) as any[];
console.log(files);
if (files.length === 0) {
message.error('请选择文件');
return;
}
// 过滤 文件 .DS_Store
files = files.filter((file) => {
if (file.webkitRelativePath.startsWith('__MACOSX')) {
return false;
}
// 过滤node_modules
if (file.webkitRelativePath.includes('node_modules')) {
return false;
}
// 过滤以.开头的文件
return !file.name.startsWith('.');
});
if (files.length === 0) {
console.log('no files');
return;
}
const root = files[0].webkitRelativePath.split('/')[0];
const formData = new FormData();
files.forEach((file) => {
// relativePath 去除第一级
const webkitRelativePath = file.webkitRelativePath.replace(root + '/', '');
formData.append('file', file, webkitRelativePath); // 保留文件夹路径
});
const key = appVersionStore.formData.key;
const version = appVersionStore.formData.version;
formData.append('appKey', key);
formData.append('version', version);
const res = await fetch('/api/app/upload', {
method: 'POST',
body: formData, //
headers: {
Authorization: 'Bearer ' + (typeof window !== 'undefined' ? localStorage.getItem('token') : ''),
},
}).then((res) => res.json());
if (res?.code === 200) {
appVersionStore.setFormData(res.data);
appVersionStore.updateByFromData();
} else {
message.error(res.message || 'Request failed');
}
// 清理之前上传的文件
e.target.value = '';
},
[appVersionStore.formData],
);
return (
<div>
<input
className='hidden'
ref={ref}
type='file'
// @ts-ignore
webkitdirectory='true'
multiple
onChange={onChange}
/>
<Button
variant='outline'
size='sm'
onClick={() => {
const key = appVersionStore.formData.key;
const version = appVersionStore.formData.version;
if (!key || !version) {
message.error('请先选择应用和版本');
return;
}
ref.current!.click();
}}>
</Button>
</div>
);
};

View File

@@ -0,0 +1,149 @@
'use client';
import { useEffect, useState } from 'react';
import { KeyParse, getTips } from './key-parse';
import { DatePicker } from './DatePicker';
import { TagsInput } from './TagsInput';
import { HelpCircle } from 'lucide-react';
import clsx from 'clsx';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export const KeyShareSelect = ({ value, onChange }: { value: string; onChange?: (value: string) => void }) => {
return (
<Select value={value || ''} onValueChange={(val) => onChange?.(val)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择共享类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value='public'></SelectItem>
<SelectItem value='protected'></SelectItem>
<SelectItem value='private'></SelectItem>
</SelectContent>
</Select>
);
};
export const KeyTextField = ({ name, value, onChange }: { name: string; value: string; onChange?: (value: string) => void }) => {
return (
<Input
name={name}
value={value}
onChange={(e) => onChange?.(e.target.value)}
className="w-full"
/>
);
};
type PermissionManagerProps = {
value: Record<string, any>;
onChange: (value: Record<string, any>) => void;
className?: string;
};
export const PermissionManager = ({ value, onChange, className }: PermissionManagerProps) => {
const [formData, setFormData] = useState<any>(value);
const [keys, setKeys] = useState<any>([]);
useEffect(() => {
const hasShare = value?.share && value?.share === 'protected';
setFormData(KeyParse.parse(value || {}));
if (hasShare) {
setKeys(['password', 'usernames', 'expiration-time']);
} else {
setKeys([]);
}
}, [value]);
const onChangeValue = (key: string, newValue: any) => {
let newFormData = { ...formData, [key]: newValue };
if (key === 'share') {
if (newValue === 'protected') {
newFormData = { ...newFormData, password: '', usernames: [], 'expiration-time': null };
onChange(KeyParse.stringify(newFormData));
setKeys(['password', 'usernames', 'expiration-time']);
} else {
delete newFormData.password;
delete newFormData.usernames;
delete newFormData['expiration-time'];
onChange(KeyParse.stringify(newFormData));
setKeys([]);
}
} else {
onChange(KeyParse.stringify(newFormData));
}
};
const tips = getTips('share');
return (
<TooltipProvider>
<form className={clsx('flex flex-col w-full gap-4', className)}>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<label className="text-sm font-medium"></label>
{tips && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-4 w-4 p-0">
<HelpCircle size={16} />
</Button>
</TooltipTrigger>
<TooltipContent className="whitespace-pre-wrap">
<p>{tips}</p>
</TooltipContent>
</Tooltip>
)}
</div>
<KeyShareSelect value={formData?.share} onChange={(value) => onChangeValue('share', value)} />
</div>
{keys.map((item: any) => {
const tips = getTips(item);
return (
<div key={item} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">{item}</label>
{tips && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-4 w-4 p-0">
<HelpCircle size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tips}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{item === 'expiration-time' && (
<DatePicker value={formData[item] || ''} onChange={(date) => onChangeValue(item, date)} />
)}
{item === 'usernames' && (
<TagsInput value={formData[item] || []} onChange={(value: string[]) => onChangeValue(item, value)} />
)}
{item !== 'expiration-time' && item !== 'usernames' && (
<KeyTextField name={item} value={formData[item] || ''} onChange={(value) => onChangeValue(item, value)} />
)}
</div>
);
})}
</form>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,62 @@
"use client"
import * as React from "react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
type TagsInputProps = {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
className?: string;
};
export function TagsInput({ value, onChange, placeholder = "输入用户名,按回车添加", className }: TagsInputProps) {
const [inputValue, setInputValue] = React.useState("")
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault()
const newValue = inputValue.trim()
if (newValue && !value.includes(newValue)) {
onChange([...value, newValue])
}
setInputValue("")
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
onChange(value.slice(0, -1))
}
}
const removeTag = (tagToRemove: string) => {
onChange(value.filter((tag) => tag !== tagToRemove))
}
return (
<div className={cn("flex flex-wrap gap-2 w-full", className)}>
{value.map((tag, index) => (
<div
key={`${tag}-${index}`}
className="flex items-center gap-1 px-3 py-1 text-sm bg-secondary text-secondary-foreground rounded-md border border-border"
>
<span>{tag}</span>
<button
type="button"
onClick={() => removeTag(tag)}
className="flex items-center justify-center w-4 h-4 rounded hover:bg-muted transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
className="flex-1 min-w-[120px] h-8"
/>
</div>
)
}

View File

@@ -0,0 +1,109 @@
'use client';
import dayjs from 'dayjs';
export const getTips = (key: string, lang?: string) => {
const tip = keysTips.find((item) => item.key === key);
if (tip) {
if (lang === 'en') {
return tip.enTips;
}
return tip.tips;
}
return '';
};
export const keysTips = [
{
key: 'share',
tips: `共享设置
1. 设置公共可以直接访问
2. 设置受保护需要登录后访问
3. 设置私有只有自己可以访问。\n
受保护可以设置密码,设置访问的用户名。切换共享状态后,需要重新设置密码和用户名。 不设置,默认是只能自己访问。`,
enTips: `1. Set public to directly access
2. Set protected to access after login
3. Set private to access only yourself.
Protected can set a password and set the username for access. After switching the shared state, you need to reset the password and username. If not set, it defaults to only being accessible to yourself.`,
},
{
key: 'content-type',
tips: `内容类型,设置文件的内容类型。默认不要修改。`,
enTips: `Content type, set the content type of the file. Default do not modify.`,
},
{
key: 'app-source',
tips: `应用来源,上传方式。默认不要修改。`,
enTips: `App source, upload method. Default do not modify.`,
},
{
key: 'cache-control',
tips: `缓存控制,设置文件的缓存控制。默认不要修改。`,
enTips: `Cache control, set the cache control of the file. Default do not modify.`,
},
{
key: 'password',
tips: `密码,设置文件的密码。不设置默认是所有人都可以访问。`,
enTips: `Password, set the password of the file. If not set, it defaults to everyone can access.`,
},
{
key: 'usernames',
tips: `用户名,设置文件的用户名。不设置默认是所有人都可以访问。`,
enTips: `Username, set the username of the file. If not set, it defaults to everyone can access.`,
parse: (value: string) => {
if (!value) {
return [];
}
return value.split(',');
},
stringify: (value: string[]) => {
if (!value) {
return '';
}
return value.join(',');
},
},
{
key: 'expiration-time',
tips: `过期时间,设置文件的过期时间。不设置默认是永久。`,
enTips: `Expiration time, set the expiration time of the file. If not set, it defaults to permanent.`,
parse: (value: Date) => {
if (!value) {
return null;
}
return dayjs(value);
},
stringify: (value?: dayjs.Dayjs) => {
if (!value) {
return '';
}
return value.toISOString();
},
},
];
export class KeyParse {
static parse(metadata: Record<string, any>) {
const keys = Object.keys(metadata);
const newMetadata = {};
keys.forEach((key) => {
const tip = keysTips.find((item) => item.key === key);
if (tip && tip.parse) {
newMetadata[key] = tip.parse(metadata[key]);
} else {
newMetadata[key] = metadata[key];
}
});
return newMetadata;
}
static stringify(metadata: Record<string, any>) {
const keys = Object.keys(metadata);
const newMetadata = {};
keys.forEach((key) => {
const tip = keysTips.find((item) => item.key === key);
if (tip && tip.stringify) {
newMetadata[key] = tip.stringify(metadata[key]);
} else {
newMetadata[key] = metadata[key];
}
});
return newMetadata;
}
}

494
src/app/apps/page.tsx Normal file
View File

@@ -0,0 +1,494 @@
'use client';
import { useShallow } from 'zustand/react/shallow';
import { useAppVersionStore, useUserAppStore } from './store';
import { useEffect, useMemo, useState } from 'react';
// import { useModal } from '@kevisual/components/modal/Confirm.tsx';
import { Plus, Code, Link as LinkIcon, Edit, Trash2, Share2, RefreshCcw, ExternalLink, Folder } from 'lucide-react';
import { isObjectNull } from '@/modules/is-null';
import clsx from 'clsx';
// import { IconButton } from '@kevisual/components/button/index.tsx';
// import { Select } from '@kevisual/components/select/index.tsx';
import { iText } from './constants';
import { PermissionManager } from './modules/PermissionManager';
import { toast as message } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Controller, useForm } from 'react-hook-form';
import { pick } from 'es-toolkit';
import copy from 'copy-to-clipboard';
import { useLayoutStore } from '@/modules/layout/store';
import { useAppDeleteModalStore, AppDeleteModal } from './modules/AppDeleteModal';
import { AppWindow, Folder as FolderIcon } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Switch } from '@/components/ui/switch';
import { AIEditorLink } from './app/AIEditorLink';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { LayoutMain } from '@/modules/layout';
import { openLink } from '@/modules/basename';
export const IconButton = (props: any) => {
return (
<button
className={clsx(
'inline-flex items-center justify-center rounded-md p-2 transition-colors hover:bg-slate-100 disabled:opacity-50 disabled:pointer-events-none',
props.className,
)}
{...props}>
{props.children}
</button>
);
};
const FormModal = () => {
const defaultValues = {
id: '',
title: '',
domain: '',
key: '',
description: '',
proxy: true,
status: 'running',
};
const { control, handleSubmit, reset } = useForm({
defaultValues,
});
const containerStore = useUserAppStore(
useShallow((state) => {
return {
showEdit: state.showEdit,
setShowEdit: state.setShowEdit,
userApp: state.userApp,
updateData: state.updateData,
};
}),
);
useEffect(() => {
const open = containerStore.showEdit;
if (open) {
const isNull = isObjectNull(containerStore.userApp);
if (isNull) {
reset(defaultValues);
} else {
reset(containerStore.userApp);
}
}
}, [containerStore.showEdit, containerStore.userApp]);
const onFinish = async (values: any) => {
const pickValues = pick(values, ['id', 'title', 'domain', 'key', 'description', 'proxy', 'status']);
containerStore.updateData(pickValues);
};
const onClose = () => {
containerStore.setShowEdit(false);
reset();
};
const isEdit = containerStore?.userApp?.id;
const isAdmin = useLayoutStore(useShallow((state) => state.isAdmin));
return (
<Dialog open={containerStore.showEdit} onOpenChange={(open) => containerStore.setShowEdit(open)}>
<DialogContent className='w-[1000px]'>
<DialogHeader>
<DialogTitle>{isEdit ? '编辑' : '添加'}</DialogTitle>
</DialogHeader>
<form className='flex flex-col gap-4 pt-2' onSubmit={handleSubmit(onFinish)}>
<div className='grid gap-2'>
<Label htmlFor='title'></Label>
<Controller name='title' control={control} render={({ field }) => <Input id='title' {...field} />} />
</div>
<div className='grid gap-2'>
<Label htmlFor='key'></Label>
<Controller name='key' control={control} render={({ field }) => <Input id='key' {...field} />} />
</div>
<div className='grid gap-2'>
<Label htmlFor='description'></Label>
<Controller
name='description'
control={control}
render={({ field }) => <Textarea id='description' {...field} rows={4} />}
/>
</div>
{isAdmin && (
<div className='grid gap-2'>
<Label htmlFor='proxy'></Label>
<Controller name='proxy' control={control} render={({ field }) => <Switch id='proxy' checked={field.value} onCheckedChange={field.onChange} />} />
</div>
)}
<div className='grid gap-2'>
<Label htmlFor='status'></Label>
<Controller
name='status'
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder='选择状态' />
</SelectTrigger>
<SelectContent>
<SelectItem value='running'></SelectItem>
<SelectItem value='stop'></SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className='flex gap-2'>
<Button type='submit'></Button>
<Button variant='outline' type='reset' onClick={onClose}>
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
const ShareModal = () => {
const [permission, setPermission] = useState<any>(null);
const [runtime, setRuntime] = useState<string[]>([]);
const containerStore = useUserAppStore(
useShallow((state) => {
return {
showEdit: state.showShareEdit,
setShowEdit: state.setShowShareEdit,
updateData: state.updateData,
userApp: state.userApp,
};
}),
);
useEffect(() => {
const open = containerStore.showEdit;
if (open) {
const permission = containerStore.userApp?.data?.permission || {};
const runtime = containerStore.userApp?.data?.runtime || [];
if (isObjectNull(permission)) {
setPermission({ share: 'private' });
} else {
setPermission(permission);
}
setRuntime(runtime);
}
}, [containerStore.showEdit, containerStore.userApp]);
const onFinish = async () => {
const values = {
id: containerStore.userApp.id,
data: {
permission,
runtime,
},
};
containerStore.updateData(values);
};
const onClose = () => {
containerStore.setShowEdit(false);
};
const isAdmin = useLayoutStore(useShallow((state) => state.isAdmin));
return (
<Dialog open={containerStore.showEdit} onOpenChange={(open) => containerStore.setShowEdit(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className='flex flex-col gap-2 w-[400px] '>
<PermissionManager
value={permission}
onChange={(value) => {
setPermission(value);
}}
/>
{isAdmin && (
<div className='grid gap-2'>
<Label></Label>
<Select
value={runtime[0] || ''}
onValueChange={(val: string) => {
setRuntime((prev) => (prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val]));
}}
>
<SelectTrigger>
<SelectValue placeholder='选择运行时' />
</SelectTrigger>
<SelectContent>
<SelectItem value='node'>Node.js</SelectItem>
<SelectItem value='browser'></SelectItem>
</SelectContent>
</Select>
<div className='flex gap-1 flex-wrap'>
{runtime.map((r) => (
<span key={r} className='bg-slate-200 px-2 py-1 rounded text-xs'>
{r}
</span>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button type='submit' onClick={() => { onFinish() }}></Button>
<Button variant='outline' type='reset' onClick={onClose}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const List = () => {
// const [modal, contextHolder] = useModal();
const userAppStore = useUserAppStore(
useShallow((state) => {
return {
list: state.list,
getList: state.getList,
setShowEdit: state.setShowEdit,
formData: state.formData,
setFormData: state.setFormData,
deleteData: state.deleteData,
setShowShareEdit: state.setShowShareEdit,
getUserApp: state.getUserApp,
};
}),
);
const appVersionStore = useAppVersionStore(
useShallow((state) => {
return {
publishVersion: state.publishVersion,
};
}),
);
const appDeleteModalStore = useAppDeleteModalStore(
useShallow((state) => {
return {
onClickDelete: state.onClickDelete,
};
}),
);
useEffect(() => {
userAppStore.getList();
}, []);
return (
<div className='w-full h-full flex bg-slate-100'>
<div className='p-2 h-full bg-white flex flex-col gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
sx={{
padding: '8px',
}}
onClick={() => {
userAppStore.setFormData({});
userAppStore.setShowEdit(true);
}}>
<Plus className='h-4 w-4' />
</IconButton>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
sx={{
padding: '8px',
}}
onClick={() => {
openLink('/domain/', '_self');
}}>
<LinkIcon className='h-4 w-4' />
</IconButton>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
<div className='grow'>
<div className='w-full h-full p-4'>
<div className='w-full h-full bg-white rounded-lg p-2 scrollbar '>
<div className='flex flex-wrap gap-2'>
{userAppStore.list.map((item) => {
const isRunning = item.status === 'running';
const hasDescription = !!item.description;
// const content = marked.parse(item.description);
const content = item.description;
return (
<div className='w-[300px] bg-white rounded-lg border border-slate-200 shadow-sm p-4 relative' key={item.id}>
<div className='flex font-bold justify-between mb-3' onClick={() => { }}>
<Tooltip>
<TooltipTrigger asChild>
<div>
{item.title} <i className='text-xs text-gray-400'>{item.key}</i>
</div>
</TooltipTrigger>
<TooltipContent><pre className=''>
<span className='text-sm'>{item.title}</span>
<i className='text-xs text-white ml-4'>{item.key}</i>
</pre></TooltipContent>
</Tooltip>
<div>
<Tooltip>
<TooltipTrigger asChild>
<div className={`${isRunning ? 'bg-green-500' : 'bg-red-500'} w-4 h-4 rounded-full`}></div>
</TooltipTrigger>
<TooltipContent>{isRunning ? '网页可正常访问' : '网页被关闭'}</TooltipContent>
</Tooltip>
</div>
</div>
<div className='flex flex-col gap-2 mb-16'>
<Tooltip>
<TooltipTrigger asChild>
<div
className='text-xs cursor-copy'
onClick={() => {
copy(item.id);
message.success('复制成功');
}}>
{item.id}
</div>
</TooltipTrigger>
<TooltipContent>App ID到剪贴板</TooltipContent>
</Tooltip>
<div className='text-xs text-gray-500'>
{item.version}
</div>
<div className={clsx('text-sm border border-slate-200 rounded p-2 max-h-[140px] overflow-auto my-1 scrollbar', !hasDescription && 'hidden')}>
{/* <div dangerouslySetInnerHTML={{ __html: content }}></div> */}
<div className='text-sm whitespace-pre-wrap'>{content}</div>
</div>
</div>
<div className='mt-4 pt-3 border-t border-slate-100 flex gap-1 absolute bottom-0 left-0 right-0 px-4 pb-4 bg-white rounded-b-lg'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
userAppStore.getUserApp(item.id);
userAppStore.setFormData(item);
userAppStore.setShowEdit(true);
}}>
<Edit className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
const url = `/apps/app?appKey=${item.key}`;
openLink(url, '_self');
}}
>
<AppWindow className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
userAppStore.getUserApp(item.id);
userAppStore.setFormData(item);
userAppStore.setShowShareEdit(true);
}}>
<Share2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent className="whitespace-pre-wrap">{iText.share.tips}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
appVersionStore.publishVersion({ appKey: item.key, version: item.version }, { showToast: true });
}}>
<RefreshCcw className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
if (isRunning) {
let baseUri = typeof window !== 'undefined' ? window.location.origin : '';
if (item.domain) {
if (item.domain.startsWith('http://') || item.domain.startsWith('https://')) {
baseUri = item.domain;
} else if (item.domain.startsWith('//')) {
baseUri = new URL(item.domain).origin;
} else {
baseUri = new URL('https://' + item.domain).toString();
}
if (baseUri.endsWith('/')) {
openLink(baseUri, '_blank');
}
console.log('baseUri', baseUri);
message.success('success');
return;
}
const link = new URL(`/${item.user}/${item.key}/`, baseUri);
openLink(link.toString(), '_blank');
} else {
message.error('应用未运行');
}
}}>
<ExternalLink className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<AIEditorLink pathname={item.key} />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={(e) => {
appDeleteModalStore.onClickDelete('user-app', item);
e.stopPropagation();
}}>
<Trash2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
</div>
</div>
</div >
{/* {contextHolder} */}
< FormModal />
<ShareModal />
<AppDeleteModal />
</div >
);
};
export default () => {
return <LayoutMain>
<List />
</LayoutMain>
};

View File

@@ -0,0 +1,152 @@
'use client';
import { isObjectNull } from '@/modules/is-null';
import { create } from 'zustand';
import { query } from '@/modules/query';
import { toast as message } from 'sonner'
type AppVersionStore = {
showEdit: boolean;
setShowEdit: (showEdit: boolean) => void;
formData: any;
setFormData: (formData: any) => void;
updateByFromData: () => void;
loading: boolean;
setLoading: (loading: boolean) => void;
key: string;
setKey: (key: string) => void;
list: any[];
getList: () => Promise<void>;
app: any;
getApp: (key: string, force?: boolean) => Promise<void>;
updateData: (data: any) => Promise<void>;
/**
* 删除应用版本
* @param id 应用版本id
* @param deleteFile 是否删除文件
* @returns
*/
deleteData: (id: string, deleteFile?: boolean) => Promise<void>;
publishVersion: (data: { id?: string; appKey?: string; version?: string }, opts?: { showToast?: boolean }) => Promise<any>;
detectVersionList: (data: { appKey: string; version: string }) => Promise<any>;
};
export const useAppVersionStore = create<AppVersionStore>((set, get) => {
return {
showEdit: false,
setShowEdit: (showEdit) => set({ showEdit }),
formData: {},
setFormData: (formData) => set({ formData }),
updateByFromData: () => {
const { formData, list } = get();
const data = list.map((item) => {
if (item.id === formData.id) {
return formData;
}
return item;
});
set({ list: data });
},
loading: false,
setLoading: (loading) => set({ loading }),
key: '',
setKey: (key) => set({ key }),
list: [],
getList: async () => {
set({ loading: true });
const key = get().key;
const res = await query.post({
path: 'app',
key: 'list',
data: {
key,
},
});
get().getApp(key, true);
set({ loading: false });
if (res.code === 200) {
set({ list: res.data });
} else {
message.error(res.message || 'Request failed');
}
},
app: {},
getApp: async (key, force) => {
const { app } = get();
if (!force && !isObjectNull(app)) {
return;
}
const res = await query.post({
path: 'user-app',
key: 'get',
data: {
key,
},
});
if (res.code === 200) {
set({ app: res.data });
} else {
message.error(res.message || '请求失败');
}
},
updateData: async (data) => {
const { getList } = get();
const res = await query.post({
path: 'app',
key: 'update',
data,
});
if (res.code === 200) {
message.success('Success');
set({ showEdit: false, formData: res.data });
getList();
} else {
message.error(res.message || 'Request failed');
}
},
deleteData: async (id, deleteFile = false) => {
const { getList } = get();
const res = await query.post({
path: 'app',
key: 'delete',
payload: {
id,
deleteFile,
},
});
if (res.code === 200) {
getList();
message.success('Success');
} else {
message.error(res.message || 'Request failed');
}
},
publishVersion: async (data, opts) => {
const showToast = opts?.showToast ?? true;
const res = await query.post({
path: 'app',
key: 'publish',
data,
});
if (res.code === 200) {
if (showToast) {
message.success('发布成功');
if (get().key) {
get().getApp(get().key, true);
}
}
} else {
if (showToast) {
message.error(res.message || '请求失败');
}
}
return res;
},
detectVersionList: async (data) => {
const res = await query.post({
path: 'app',
key: 'detectVersionList',
data,
});
return res;
},
};
});

View File

@@ -0,0 +1,2 @@
export * from './user-app';
export * from './app-version';

View File

@@ -0,0 +1,101 @@
'use client';
import { create } from 'zustand';
import { query } from '@/modules/query';
import { toast as message } from 'sonner'
type UserAppStore = {
showEdit: boolean;
setShowEdit: (showEdit: boolean) => void;
formData: any;
setFormData: (formData: any) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
list: any[];
getList: () => Promise<void>;
updateData: (data: any) => Promise<void>;
/**
* 删除用户应用
* @param id 用户应用id
* @param deleteFile 是否删除文件
* @returns
*/
deleteData: (id: string, deleteFile?: boolean) => Promise<void>;
showShareEdit: boolean;
setShowShareEdit: (showShareEdit: boolean) => void;
userApp: any;
setUserApp: (userApp: any) => void;
getUserApp: (id: string) => Promise<void>;
};
export const useUserAppStore = create<UserAppStore>((set, get) => {
return {
showEdit: false,
setShowEdit: (showEdit) => set({ showEdit }),
formData: {},
setFormData: (formData) => set({ formData }),
loading: false,
setLoading: (loading) => set({ loading }),
list: [],
getList: async () => {
set({ loading: true });
const res = await query.post({
path: 'user-app',
key: 'list',
});
set({ loading: false });
if (res.code === 200) {
set({ list: res.data });
} else {
message.error(res.message || 'Request failed');
}
},
updateData: async (data) => {
const { getList } = get();
const res = await query.post({
path: 'user-app',
key: 'update',
data,
});
if (res.code === 200) {
message.success('Success');
set({ showEdit: false, showShareEdit: false, formData: res.data });
getList();
} else {
message.error(res.message || 'Request failed');
}
},
deleteData: async (id, deleteFile = false) => {
const { getList } = get();
const res = await query.post({
path: 'user-app',
key: 'delete',
payload: {
id,
deleteFile,
},
});
if (res.code === 200) {
getList();
message.success('Success');
} else {
message.error(res.message || 'Request failed');
}
},
showShareEdit: false,
setShowShareEdit: (showShareEdit) => set({ showShareEdit }),
userApp: {},
setUserApp: (userApp) => set({ userApp }),
getUserApp: async (id) => {
set({ userApp: null });
const res = await query.post({
path: 'user-app',
key: 'get',
payload: { id }
});
if (res.code === 200) {
set({ userApp: res.data });
} else {
message.error(res.message || 'Request failed');
}
},
};
});

205
src/app/domain/page.tsx Normal file
View File

@@ -0,0 +1,205 @@
'use client';
import { useEffect } from 'react';
import { appDomainStatus, useDomainStore } from './store/index ';
import { useForm, Controller } from 'react-hook-form';
import { pick } from 'es-toolkit';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Plus, Pencil, Trash2 } from 'lucide-react';
import { LayoutUser } from '@/modules/layout/LayoutUser';
import { LayoutMain } from '@/modules/layout';
const TableList = () => {
const { list, setShowEditModal, setFormData, deleteDomain } = useDomainStore();
useEffect(() => {
// Initial load is handled by the parent component
}, []);
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead>UID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((domain) => (
<TableRow key={domain.id}>
<TableCell>{domain.id}</TableCell>
<TableCell>{domain.domain}</TableCell>
<TableCell>{domain.appId}</TableCell>
<TableCell>{domain.uid}</TableCell>
<TableCell>{domain.status}</TableCell>
<TableCell className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setShowEditModal(true);
setFormData(domain);
}}>
<Pencil className="w-4 h-4 mr-1" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteDomain(domain)}>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
const FomeModal = () => {
const { showEditModal, setShowEditModal, formData, updateDomain } = useDomainStore();
const {
handleSubmit,
formState: { errors },
reset,
control,
setValue,
} = useForm();
useEffect(() => {
if (!showEditModal) return;
if (formData?.id) {
reset(formData);
} else {
reset({
status: 'running',
});
}
}, [formData, showEditModal, reset]);
const onSubmit = async (data: any) => {
const _formData = pick(data, ['domain', 'appId', 'status', 'id']);
if (formData.id) {
_formData.id = formData.id;
}
const res = await updateDomain(_formData);
if (res.code === 200) {
setShowEditModal(false);
}
};
return (
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="p-4 w-[500px]">
<form className="w-full flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium"></label>
<Input
{...control.register('domain', { required: '请输入域名' })}
placeholder="请输入域名"
className={errors.domain ? "border-red-500" : ""}
/>
{errors.domain && <span className="text-xs text-red-500">{errors.domain.message as string}</span>}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">ID</label>
<Input
{...control.register('appId', { required: '请输入应用ID' })}
placeholder="请输入应用ID"
className={errors.appId ? "border-red-500" : ""}
/>
{errors.appId && <span className="text-xs text-red-500">{errors.appId.message as string}</span>}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium"></label>
<Controller
name="status"
control={control}
defaultValue=""
render={({ field }) => (
<Select value={field.value || ''} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="请选择状态" />
</SelectTrigger>
<SelectContent>
{appDomainStatus.map((item) => (
<SelectItem key={item} value={item}>{item}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<Button type="submit"></Button>
</form>
</div>
</DialogContent>
</Dialog>
);
};
export const List = () => {
const { getDomainList, setShowEditModal, setFormData } = useDomainStore();
useEffect(() => {
getDomainList();
}, [getDomainList]);
return (
<div className="p-4 w-full h-full">
<div className="flex mb-4">
<Dialog>
<DialogTrigger asChild>
<Button
onClick={() => {
setShowEditModal(true);
setFormData({});
}}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</DialogTrigger>
</Dialog>
</div>
<TableList />
<FomeModal />
</div>
);
};
export default () => {
return <LayoutMain><List /></LayoutMain>;
}

View File

@@ -0,0 +1,92 @@
'use strict';
import { create } from 'zustand';
import { query } from '@/modules/query';
import { toast } from 'sonner';
// 审核,通过,驳回
export const appDomainStatus = ['audit', 'auditReject', 'auditPending', 'running', 'stop'] as const;
type AppDomainStatus = (typeof appDomainStatus)[number];
type Domain = {
id: string;
domain: string;
appId?: string;
status: AppDomainStatus;
data?: any;
uid?: string;
createdAt: string;
updatedAt: string;
};
interface Store {
getDomainList: () => Promise<any>;
updateDomain: (data: { domain: string; id: string; [key: string]: any }, opts?: { refresh?: boolean }) => Promise<any>;
deleteDomain: (data: { id: string }) => Promise<any>;
getDomainDetail: (data: { domain?: string; id?: string }) => Promise<any>;
list: Domain[];
setList: (list: Domain[]) => void;
formData: any;
setFormData: (formData: any) => void;
showEditModal: boolean;
setShowEditModal: (showEditModal: boolean) => void;
}
export const useDomainStore = create<Store>((set, get) => ({
getDomainList: async () => {
const res = await query.get({
path: 'app.domain.manager',
key: 'list',
});
if (res.code === 200) {
set({ list: res.data?.list || [] });
}
return res;
},
updateDomain: async (data: any, opts?: { refresh?: boolean }) => {
const res = await query.post({
path: 'app.domain.manager',
key: 'update',
data,
});
if (res.code === 200) {
const list = get().list;
set({ list: list.map((item) => (item.id === data.id ? res.data : item)) });
toast.success('更新成功');
if (opts?.refresh ?? true) {
get().getDomainList();
}
} else {
toast.error(res.message || '更新失败');
}
return res;
},
deleteDomain: async (data: any) => {
const res = await query.post({
path: 'app.domain.manager',
key: 'delete',
data,
});
if (res.code === 200) {
const list = get().list;
set({ list: list.filter((item) => item.id !== data.id) });
toast.success('删除成功');
}
return res;
},
getDomainDetail: async (data: any) => {
const res = await query.post({
path: 'app.domain.manager',
key: 'get',
data,
});
if (res.code === 200) {
set({ formData: res.data });
}
return res;
},
list: [],
setList: (list: any[]) => set({ list }),
formData: {},
setFormData: (formData: any) => set({ formData }),
showEditModal: false,
setShowEditModal: (showEditModal: boolean) => set({ showEditModal }),
}));

View File

@@ -123,3 +123,42 @@
@apply bg-background text-foreground;
}
}
html,body {
height: 100%;
overflow: hidden;
}
@utility scrollbar {
overflow: auto;
/* 整个滚动条 */
&::-webkit-scrollbar {
width: 3px;
height: 3px;
}
&::-webkit-scrollbar-track {
background-color: var(--color-scrollbar-track);
}
/* 滚动条有滑块的轨道部分 */
&::-webkit-scrollbar-track-piece {
background-color: transparent;
border-radius: 1px;
}
/* 滚动条滑块(竖向:vertical 横向:horizontal) */
&::-webkit-scrollbar-thumb {
cursor: pointer;
background-color: var(--color-scrollbar-thumb);
border-radius: 5px;
}
/* 滚动条滑块hover */
&::-webkit-scrollbar-thumb:hover {
background-color: var(--color-scrollbar-thumb-hover);
}
/* 同时有垂直和水平滚动条时交汇的部分 */
&::-webkit-scrollbar-corner {
display: block; /* 修复交汇时出现的白块 */
}
}

View File

@@ -1,10 +1,14 @@
'use client';
import { LayoutMain } from "@/modules/layout";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
Light Code
</main>
<LayoutMain>
<iframe src="/root/router-studio" className="w-full border-0" style={{
height: 'calc(100vh - 48px)'
}}/>
</LayoutMain>
</div>
);
}

BIN
src/assets/panda.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
src/assets/qrcode-8x8.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,99 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div className="relative w-full overflow-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
)
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className="text-muted-foreground mt-4 text-sm"
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:outline-hidden",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -1,8 +1,8 @@
const isDev = process.env.NODE_ENV === "development";
const BASE_NAME = isDev ? '' : '/root/perler-beads';
export const isDev = process.env.NODE_ENV === "development";
const BASE_NAME = isDev ? '' : '/root/center';
export const basename = BASE_NAME;
export const wrapBasename = (path: string) => {
const hasEnd = path.endsWith('/')
let _basename = basename;
@@ -14,5 +14,13 @@ export const wrapBasename = (path: string) => {
if (isDev) {
return _basename
}
return _basename + '.html';
return !hasEnd ? _basename + '/' : _basename;
}
export const openLink = (path: string, target: string = '_self') => {
if (path.startsWith('http://') || path.startsWith('https://')) {
window.open(path, target);
return;
}
const url = wrapBasename(path);
window.open(url, target);
}

9
src/modules/is-null.ts Normal file
View File

@@ -0,0 +1,9 @@
export const isObjectNull = (value: any) => {
if (value === null || value === undefined) {
return true;
}
if (JSON.stringify(value) === '{}') {
return true;
}
return false;
};

View File

@@ -0,0 +1,178 @@
'use strict';
import { useShallow } from 'zustand/react/shallow';
import { useLayoutStore } from './store';
import clsx from 'clsx';
import { toast as message } from 'sonner';
import { useMemo } from 'react';
import { queryLogin } from '../query';
import { LogOut, Map, SquareUser, Users, X, ArrowDownLeftFromSquareIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { openLink } from '../basename';
export const LayoutUser = () => {
const { open, setOpen, isAdmin, ...store } = useLayoutStore(
useShallow((state) => ({
open: state.openUser,
setOpen: state.setOpenUser,
me: state.me,
switchOrg: state.switchOrg,
isAdmin: state.isAdmin,
})),
);
const items = useMemo(() => {
const orgs = store.me?.orgs || [];
return orgs.map((item) => {
return {
label: item,
key: item,
icon: <Users size={16} />,
};
});
}, [store.me]);
const menu = useMemo(() => {
const orgs = store.me?.orgs || [];
const hasOrg = orgs.length > 0;
type MenuItem = {
title: string;
icon: React.ReactNode;
link?: string;
isOrg?: boolean;
isAdmin?: boolean;
}
const menuItems: MenuItem[] = [
// {
// title: '个人中心',
// icon: <SquareUser size={16} />,
// link: '/user/profile',
// },
// {
// title: '我的组织',
// icon: <Users size={16} />,
// link: '/org/edit/list',
// isOrg: true,
// },
// {
// title: '站点地图',
// icon: <Map size={16} />,
// link: '/map',
// },
{
title: '域名管理',
icon: <ArrowDownLeftFromSquareIcon size={16} />,
link: '/domain/',
isAdmin: true,
},
];
return menuItems.filter((item) => {
if (item.isOrg) {
return hasOrg;
}
if (item.isAdmin) {
return isAdmin;
}
return true;
});
}, [store.me]);
return (
<TooltipProvider>
<div className={clsx('w-full h-full absolute z-20 no-drag text-primary', !open && 'hidden')}>
<div
className='w-full absolute h-full opacity-60 z-0'
onClick={() => {
setOpen(false);
}}></div>
<div className='w-[400px] bg-white transition-all duration-300 h-full absolute top-0 right-0 rounded-l-lg'>
<div className='flex justify-between p-6 mt-4 font-bold items-center border-b'>
<div className='flex items-center gap-2'>
: <span className='text-primary'>{store.me?.username}</span>
</div>
<div className='flex gap-4'>
{items.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='icon'>
<Users />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{items.map((item, index) => (
<DropdownMenuItem
key={index}
onClick={() => {
store.switchOrg(item.key, 'org');
}}>
<div className='mr-2'>{item.icon}</div>
<div>{item.label}</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
)}
<Button variant='ghost' size='icon' onClick={() => setOpen(false)}>
<X />
</Button>
</div>
</div>
<div className='mt-3 font-medium'>
{menu.map((item, index) => {
return (
<div
key={index}
className='flex items-center p-4 hover:bg-secondary hover:text-white cursor-pointer'
onClick={() => {
if (item.link) {
openLink(item.link, '_self');
setOpen(false);
} else {
message.info('即将上线');
}
}}>
<div className='mr-4'>{item.icon}</div>
<div>{item.title}</div>
</div>
);
})}
</div>
<div
className='flex items-center p-4 hover:bg-secondary hover:text-white cursor-pointer'
onClick={async () => {
const res = await queryLogin.logout();
if (res.success) {
const url = new URL(location.origin);
url.pathname = '/root/login';
openLink(url.toString(), '_self');
} else {
message.error(res.message || '退出失败');
}
}}>
<div className='mr-4'>
<LogOut size={16} />
</div>
<div>退</div>
</div>
</div>
</div>
</TooltipProvider>
);
};

104
src/modules/layout/Menu.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { useShallow } from 'zustand/react/shallow';
import { useLayoutStore } from './store';
import clsx from 'clsx';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import HomeOutlined from '@ant-design/icons/HomeOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import FolderOutlined from '@ant-design/icons/FolderOutlined';
import CodeOutlined from '@ant-design/icons/CodeOutlined';
import SwitcherOutlined from '@ant-design/icons/SwitcherOutlined';
import SmileOutlined from '@ant-design/icons/SmileOutlined';
import { X, Settings } from 'lucide-react';
import { Map } from 'lucide-react';
import { openLink } from '../basename';
export const useQuickMenu = () => {
return [
{
title: '首页',
icon: <HomeOutlined />,
link: '/',
},
{
title: '应用',
icon: <AppstoreOutlined />,
link: '/apps/',
},
// {
// title: '文件',
// icon: <FolderOutlined />,
// link: '/file/edit/list',
// },
];
};
export const LayoutMenu = () => {
const meun = [
{
title: '首页',
icon: <HomeOutlined />,
link: '/',
},
// {
// title: '应用',
// icon: <AppstoreOutlined />,
// link: '/app/edit/list',
// },
// {
// title: '文件',
// icon: <FolderOutlined />,
// link: '/file/edit/list',
// },
// {
// title: '容器',
// icon: <CodeOutlined />,
// link: '/container/edit/list',
// },
// { title: '配置', icon: <Settings size={16} />, link: '/config/edit/list' },
// { title: '地图', icon: <Map size={16} />, link: '/map' },
// {
// title: '关于',
// icon: <SmileOutlined />,
// },
];
const { open, setOpen } = useLayoutStore(useShallow((state) => ({ open: state.open, setOpen: state.setOpen })));
return (
<div className={clsx('w-full h-full text-primary absolute z-20 no-drag', !open && 'hidden')}>
<div
className='bg-white w-full absolute h-full opacity-60 z-0'
onClick={() => {
setOpen(false);
}}></div>
<div className='w-[300px] h-full absolute top-0 left-0 '>
<div className='flex justify-between p-6 mt-4 font-bold items-center'>
Envision Center
<div>
<Button variant='ghost' size='icon' onClick={() => setOpen(false)}>
<X />
</Button>
</div>
</div>
<div className='mt-3 font-medium'>
{meun.map((item, index) => {
return (
<div
key={index}
className='flex items-center p-4 gap-3 cursor-pointer hover:bg-secondary hover:text-white rounded-md'
onClick={() => {
if (item.link) openLink(item.link, '_self');
else {
toast.info('关于 Envision Center');
}
setOpen(false);
}}>
<div className='w-6 h-6 flex items-center justify-center'>{item.icon}</div>
<div>{item.title}</div>
</div>
);
})}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,147 @@
import { MenuOutlined, SwapOutlined } from '@ant-design/icons';
import { LayoutMenu, useQuickMenu } from './Menu';
import { useLayoutStore, usePlatformStore } from './store';
import { useShallow } from 'zustand/react/shallow';
import { useEffect, useLayoutEffect, useState } from 'react';
import { LayoutUser } from './LayoutUser';
import PandaPNG from '@/assets/panda.jpg';
import QRCodePNG from '@/assets/qrcode-8x8.jpg';
import clsx from 'clsx';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export const IconButton = (props: any) => {
return (
<button
className={clsx(
'inline-flex items-center justify-center rounded-md p-2 transition-colors hover:bg-slate-100 disabled:opacity-50 disabled:pointer-events-none',
props.className,
)}
{...props}>
{props.children}
</button>
);
};
import { QrCode } from 'lucide-react';
import { openLink } from '../basename';
type LayoutMainProps = {
title?: React.ReactNode;
children?: React.ReactNode;
};
export const LayoutMain = (props: LayoutMainProps) => {
const menuStore = useLayoutStore(
useShallow((state) => {
return {
open: state.open,
setOpen: state.setOpen, //
getMe: state.getMe,
me: state.me,
setOpenUser: state.setOpenUser,
switchOrg: state.switchOrg,
};
}),
);
const platformStore = usePlatformStore(
useShallow((state) => {
return {
isMac: state.isMac,
mount: state.mount,
isElectron: state.isElectron,
init: state.init,
};
}),
);
const { isMac, mount, isElectron } = platformStore;
const quickMenu = useQuickMenu();
useLayoutEffect(() => {
platformStore.init();
}, []);
useEffect(() => {
menuStore.getMe();
}, []);
return (
<div className='flex w-full h-full flex-col relative'>
<div
className={clsx('layout-menu items-center ', !mount && '!invisible')}
style={{
cursor: isElectron ? 'move' : 'default',
}}>
<div className='flex grow justify-between pl-4 py-2 items-center bg-gray-200'>
<div className='flex items-center gap-2'>
<div className='text-xl font-bold '>{props.title}</div>
<div className='flex items-center gap-2 text-sm '>
{quickMenu.map((item, index) => {
if (typeof window === 'undefined') return null;
const isActive = location?.pathname === item.link;
return (
<div
key={index}
className={clsx('flex items-center gap-2 px-1', isActive && 'border border-white')}
onClick={() => {
openLink(item.link, '_self');
}}>
<Tooltip>
<TooltipTrigger asChild>
<div className='cursor-pointer'>{item.icon}</div>
</TooltipTrigger>
<TooltipContent>{item.title}</TooltipContent>
</Tooltip>
</div>
);
})}
</div>
</div>
<div className='mr-4 flex gap-4 items-center no-drag'>
<div className='group relative'>
<IconButton>
<QrCode size={16} />
</IconButton>
<div className='absolute hidden group-hover:flex bg-white p-2 border shadow-md top-10 -left-15 w-40 z-[9999] flex-col items-center justify-center rounded-md'>
<img src={QRCodePNG.src} alt='QR Code' />
<div className='text-sm text-black'></div>
</div>
</div>
{menuStore.me?.type === 'org' && (
<div>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
onClick={() => {
menuStore.switchOrg('', 'user');
}}>
<SwapOutlined />
</IconButton>
</TooltipTrigger>
<TooltipContent>Switch To User</TooltipContent>
</Tooltip>
</div>
)}
<div className='w-8 h-8 rounded-full avatar cursor-pointer' onClick={() => menuStore.setOpenUser(true)}>
{menuStore.me?.avatar ? (
<img className='w-8 h-8 rounded-full' src={menuStore.me?.avatar} alt='avatar' />
) : (
<img className='w-8 h-8 rounded-full' src={PandaPNG.src} alt='avatar' />
)}
</div>
<div className='cursor-pointer' onClick={() => menuStore.setOpenUser(true)}>
{menuStore.me?.username}
</div>
</div>
</div>
<div
className='flex'
style={{
height: 'calc(100vh - 3rem)',
}}>
{props.children}
</div>
</div>
<LayoutUser />
</div>
);
};

View File

@@ -0,0 +1,104 @@
'use strict';
import { query, queryLogin } from '@/modules/query';
import { create } from 'zustand';
import { toast as message } from 'sonner';
export const getIsMac = async () => {
// @ts-ignore
const userAgentData = navigator.userAgentData;
if (userAgentData) {
const ua = await userAgentData.getHighEntropyValues(['platform']);
if (ua.platform === 'macOS') {
return true;
}
}
return false;
};
export const getIsElectron = () => {
// 检查 window.process 和 navigator.userAgent 中是否包含 Electron 信息
return (
// @ts-ignore
(typeof window !== 'undefined' && typeof window.process !== 'undefined' && window.process.type === 'renderer') ||
(typeof navigator === 'object' && typeof navigator.userAgent === 'string' && navigator.userAgent.indexOf('Electron') >= 0)
);
};
type PlatfromStore = {
isMac: boolean;
setIsMac: (mac: boolean) => void;
mount: boolean;
isElectron: boolean;
init: () => Promise<void>;
};
export const usePlatformStore = create<PlatfromStore>((set) => {
return {
isMac: false,
mount: false,
isElectron: false,
setIsMac: (mac) => set({ isMac: mac }),
init: async () => {
const mac = await getIsMac();
// @ts-ignore
const isElectron = getIsElectron();
set({ isMac: isElectron && mac, isElectron: isElectron, mount: true });
},
};
});
type Me = {
id?: string;
username?: string;
needChangePassword?: boolean;
role?: string;
description?: string;
type?: 'user' | 'org';
orgs?: string[];
avatar?: string;
};
export type LayoutStore = {
open: boolean;
setOpen: (open: boolean) => void;
me: Me;
setMe: (me: Me) => void;
getMe: () => Promise<void>;
openUser: boolean;
setOpenUser: (openUser: boolean) => void;
switchOrg: (username?: string, type?: 'user' | 'org') => Promise<void>;
isAdmin: boolean;
setIsAdmin: (isAdmin: boolean) => void;
checkHasOrg: () => boolean;
};
export const useLayoutStore = create<LayoutStore>((set, get) => ({
open: false,
setOpen: (open) => set({ open }),
me: {},
setMe: (me) => set({ me }),
getMe: async () => {
const res = await queryLogin.getMe();
if (res.code === 200) {
set({ me: res.data });
set({ isAdmin: res.data.orgs?.includes('admin') });
}
},
openUser: false,
setOpenUser: (openUser) => set({ openUser }),
switchOrg: async (username?: string, type?: string) => {
const res = await queryLogin.switchUser(username || '');
if (res.code === 200) {
message.success('Switch success');
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
message.error(res.message || 'Request failed');
}
},
isAdmin: false,
setIsAdmin: (isAdmin) => set({ isAdmin }),
checkHasOrg: () => {
const user = get().me || {};
if (!user.orgs) {
return false;
}
return user?.orgs?.length > 0;
},
}));

32
src/modules/query.ts Normal file
View File

@@ -0,0 +1,32 @@
'use client';
import { QueryClient } from '@kevisual/query';
import { QueryLoginBrowser } from '@kevisual/api/login';
import { toast } from 'sonner';
// Only create instances in browser environment
const isBrowser = typeof window !== 'undefined';
export const query = isBrowser ? new QueryClient({}) : {} as QueryClient;
export const queryLogin = isBrowser
? new QueryLoginBrowser({
query: query as any,
})
: {} as QueryLoginBrowser;
if (isBrowser) {
(query as any).afterResponse = async (res, ctx) => {
const newRes = await queryLogin.run401Action(res, ctx, {
afterAlso401: () => {},
afterCheck: (res: any) => {
if (res.code === 200) {
toast.success('刷新登陆信息');
setTimeout(() => {
window.location.reload();
}, 2000);
}
},
});
return newRes as any;
};
}

View File

@@ -1,34 +1,20 @@
{
"extends": "@kevisual/types/json/next.json",
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"strict": false,
"paths": {
"@/*": [
"./src/*"
]
}
},
"baseUrl": "./",
"typeRoots": [
"node_modules/@types"
],
},
"include": [
"src",
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
@@ -41,4 +27,4 @@
"exclude": [
"node_modules"
]
}
}