feat: Implement view management features with a new UI for editing and listing views

- Added a resizable panel layout in the studio app to display the view list alongside the main application.
- Refactored the studio store to include new methods for fetching and managing route views.
- Introduced a new DataItemForm component for configuring data items in views.
- Created a ViewEditor component for adding and editing views, including data items and queries.
- Enhanced the ViewList component to support searching, adding, editing, and deleting views.
- Updated UI components (Button, Checkbox, Dialog, Input, Label, Table) for better styling and functionality.
- Added environment configuration for API URL.
- Introduced a new workspace configuration for pnpm.
This commit is contained in:
2025-12-31 17:54:11 +08:00
parent 8670fd3bfc
commit 231caa3b9a
22 changed files with 1177 additions and 116 deletions

View File

@@ -1,5 +1,7 @@
{
"dependencies": {
"nanoid": "^5.1.6"
"nanoid": "^5.1.6",
"zod": "^4.2.1",
"zod-to-json-schema": "^3.25.1"
}
}

20
pnpm-lock.yaml generated
View File

@@ -11,6 +11,12 @@ importers:
nanoid:
specifier: ^5.1.6
version: 5.1.6
zod:
specifier: ^4.2.1
version: 4.2.1
zod-to-json-schema:
specifier: ^3.25.1
version: 3.25.1(zod@4.2.1)
packages:
@@ -19,6 +25,20 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
zod-to-json-schema@3.25.1:
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
peerDependencies:
zod: ^3.25 || ^4
zod@4.2.1:
resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==}
snapshots:
nanoid@5.1.6: {}
zod-to-json-schema@3.25.1(zod@4.2.1):
dependencies:
zod: 4.2.1
zod@4.2.1: {}

1
web/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL='http://localhost:4005'

3
web/.gitignore vendored
View File

@@ -4,3 +4,6 @@ node_modules
.astro
dist
.env
!.env*example

View File

@@ -11,6 +11,7 @@ dotenv.config();
const isDev = process.env.NODE_ENV === 'development';
let target = process.env.VITE_API_URL || 'http://localhost:51515';
console.log('API Proxy Target:', target);
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
let proxy = {
'/root/': apiProxy,

View File

@@ -23,11 +23,14 @@
"@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.6.0",
"@astrojs/vue": "^5.1.3",
"@kevisual/cache": "^0.0.5",
"@kevisual/context": "^0.0.4",
"@kevisual/query": "^0.0.33",
"@kevisual/query-login": "^0.0.7",
"@kevisual/registry": "^0.0.1",
"@kevisual/router": "^0.0.51",
"@kevisual/router": "^0.0.52",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18",
"@uiw/react-md-editor": "^4.0.11",
@@ -39,13 +42,17 @@
"dayjs": "^1.11.19",
"es-toolkit": "^1.43.0",
"github-markdown-css": "^5.8.1",
"handsontable": "^16.2.0",
"highlight.js": "^11.11.1",
"lucide-react": "^0.562.0",
"marked": "^17.0.1",
"marked-highlight": "^2.2.3",
"nanoid": "^5.1.6",
"papaparse": "^5.5.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.69.0",
"react-resizable-panels": "^4.1.0",
"react-toastify": "^11.0.5",
"tailwind-merge": "^3.4.0",
"vue": "^3.5.26",
@@ -55,7 +62,7 @@
"access": "public"
},
"devDependencies": {
"@kevisual/api": "^0.0.10",
"@kevisual/api": "^0.0.14",
"@kevisual/types": "^0.0.10",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -63,7 +70,7 @@
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
},
"packageManager": "pnpm@10.26.2",
"packageManager": "pnpm@10.27.0",
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"esbuild",

265
web/pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@astrojs/vue':
specifier: ^5.1.3
version: 5.1.3(@types/node@24.7.2)(astro@5.16.6(@types/node@24.7.2)(idb-keyval@6.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.4)(typescript@5.9.3))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.4)(vue@3.5.26(typescript@5.9.3))
'@kevisual/cache':
specifier: ^0.0.5
version: 0.0.5
'@kevisual/context':
specifier: ^0.0.4
version: 0.0.4
@@ -33,8 +36,14 @@ importers:
specifier: ^0.0.1
version: 0.0.1(typescript@5.9.3)
'@kevisual/router':
specifier: ^0.0.51
version: 0.0.51
specifier: ^0.0.52
version: 0.0.52
'@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)
'@radix-ui/react-label':
specifier: ^2.1.8
version: 2.1.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)
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.7)(react@19.2.3)
@@ -46,7 +55,7 @@ importers:
version: 4.0.11(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
antd:
specifier: ^6.1.3
version: 6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 6.1.3(moment@2.30.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
astro:
specifier: ^5.16.6
version: 5.16.6(@types/node@24.7.2)(idb-keyval@6.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.52.4)(typescript@5.9.3)
@@ -68,6 +77,9 @@ importers:
github-markdown-css:
specifier: ^5.8.1
version: 5.8.1
handsontable:
specifier: ^16.2.0
version: 16.2.0
highlight.js:
specifier: ^11.11.1
version: 11.11.1
@@ -83,12 +95,21 @@ importers:
nanoid:
specifier: ^5.1.6
version: 5.1.6
papaparse:
specifier: ^5.5.3
version: 5.5.3
react:
specifier: ^19.2.3
version: 19.2.3
react-dom:
specifier: ^19.2.3
version: 19.2.3(react@19.2.3)
react-hook-form:
specifier: ^7.69.0
version: 7.69.0(react@19.2.3)
react-resizable-panels:
specifier: ^4.1.0
version: 4.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-toastify:
specifier: ^11.0.5
version: 11.0.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -103,8 +124,8 @@ importers:
version: 5.0.9(@types/react@19.2.7)(react@19.2.3)
devDependencies:
'@kevisual/api':
specifier: ^0.0.10
version: 0.0.10
specifier: ^0.0.14
version: 0.0.14
'@kevisual/types':
specifier: ^0.0.10
version: 0.0.10
@@ -539,6 +560,9 @@ packages:
cpu: [x64]
os: [win32]
'@handsontable/pikaday@1.0.0':
resolution: {integrity: sha512-1VN6N38t5/DcjJ7y7XUYrDx1LuzvvzlrFdBdMG90Qo1xc8+LXHqbWbsTEm5Ec5gXTEbDEO53vUT35R+2COmOyg==}
'@img/colour@1.0.0':
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
engines: {node: '>=18'}
@@ -681,17 +705,20 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kevisual/api@0.0.10':
resolution: {integrity: sha512-AF5DcXPfVEZtvIJw9EC8EXkhU33dS08v9+b4mIrzCi0ETRvwAlQ2cg8WgfY6exJYzbFg6M4h+POhXvPujrk9mA==}
'@kevisual/api@0.0.14':
resolution: {integrity: sha512-GOs61Jvjxs+7PB8+iSPko9/RGeWENxltHueV75M6W0psRsnx/J+06I48/cO413FwCoqSOqpOoivdRgSENdHM9g==}
'@kevisual/cache@0.0.3':
resolution: {integrity: sha512-BWEck69KYL96/ywjYVkML974RHjDJTj2ITQND1zFPR+hlBV1H1p55QZgSYRJCObg3EAV1S9Zic/fR2T4pfe8yg==}
'@kevisual/cache@0.0.5':
resolution: {integrity: sha512-fgtUYGUUq/DY0KFV4CkWszNqvQUaA8XvMTUjoR9ZXRpau5IIDolD/Wen2TFsZ7G3Rfy+lef5dnaiZVDkZwdVKg==}
'@kevisual/context@0.0.4':
resolution: {integrity: sha512-HJeLeZQLU+7tCluSfOyvkgKLs0HjCZrdJlZgEgKRSa8XTwZfMAUt6J7qZTbrZAHBlPtX68EPu/PI8JMCeu3WAQ==}
'@kevisual/js-filter@0.0.2':
resolution: {integrity: sha512-SS8diRpjrAIEQKT8YMTa1XTucQKuPbG04UChXtp7wd1jPsvQaNKYapErRA8qx4igwoVQt6eAYADwYzXhB1fN2A==}
'@kevisual/js-filter@0.0.3':
resolution: {integrity: sha512-vgUB2fUAWS75GUFr/a/tGSSDrPUUmVDktO38k3hIKwU3ZE4tpuhcVxrpUbkXlFS5i0rbL2mAQeID1C6kIlMGRg==}
'@kevisual/load@0.0.6':
resolution: {integrity: sha512-+3YTFehRcZ1haGel5DKYMUwmi5i6f2psyaPZlfkKU/cOXgkpwoG9/BEqPCnPjicKqqnksEpixVRkyHJ+5bjLVA==}
@@ -707,8 +734,8 @@ packages:
'@kevisual/registry@0.0.1':
resolution: {integrity: sha512-//OHu9m4JDrMjgP8o8dcjZd3D3IAUkRVlkTSviouZEH7r5m7mccA3Hvzw0XJ/lelx6exC6LWsyv6c4uV0Dp+gw==}
'@kevisual/router@0.0.51':
resolution: {integrity: sha512-i9qYBeS/um78oC912oWJD3iElB+5NTKyTrz1Hzf4DckiUFnjLL81UPwjIh5I2l9+ul0IZ/Pxx+sFSF99fJkzKg==}
'@kevisual/router@0.0.52':
resolution: {integrity: sha512-Qiv3P1XjzD813Tm79S+atrDb2eickGCI9tuy/aCu512LcoYYJqZhwwkeT4ES0DinnA13Ckqd43QWBR6UmuYkHQ==}
'@kevisual/types@0.0.10':
resolution: {integrity: sha512-Q73uzzjk9UidumnmCvOpgzqDDvQxsblz22bIFuoiioUFJWwaparx8bpd8ArRyFojicYL1YJoFDzDZ9j9NN8grA==}
@@ -763,6 +790,19 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
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-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
@@ -838,6 +878,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-label@2.1.8':
resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
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-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
@@ -953,6 +1006,24 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-previous@1.1.1':
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@rc-component/async-validator@5.0.4':
resolution: {integrity: sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==}
engines: {node: '>=14.x'}
@@ -1541,6 +1612,9 @@ packages:
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -1741,6 +1815,9 @@ packages:
bcp-47-match@2.0.3:
resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
birpc@2.8.0:
resolution: {integrity: sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==}
@@ -1793,6 +1870,9 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
chevrotain@6.5.0:
resolution: {integrity: sha512-BwqQ/AgmKJ8jcMEjaSnfMybnKMgGTrtDKowfTP3pX4jwVy0kNjRsT/AP6h+wC3+3NC+X8X15VWBnTCQlX+wQFg==}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -1855,6 +1935,9 @@ packages:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
core-js@3.47.0:
resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==}
cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
@@ -1977,6 +2060,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@@ -2157,6 +2243,9 @@ packages:
h3@1.15.4:
resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==}
handsontable@16.2.0:
resolution: {integrity: sha512-4zhMQON9DPyip/6YIPH2G7jN+QEJ0uabCZruhrhOqTqr3Qf/FDjsTInUaEzMCmhhdii5MbA6PGyLfUad6t1sXA==}
hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
@@ -2241,6 +2330,9 @@ packages:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
hyperformula@3.1.1:
resolution: {integrity: sha512-v+yvRPZGL73KinH2lvS4/1QMe2xNviTfgIcVgKjzKGi66xEuvuoDRgQ48ODc4XhD+c+JLNfs9Ln1GnHQ5TDNGA==}
i18next-browser-languagedetector@8.2.0:
resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==}
@@ -2432,6 +2524,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.4:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -2641,6 +2737,9 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -2694,6 +2793,9 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
numbro@2.5.0:
resolution: {integrity: sha512-xDcctDimhzko/e+y+Q2/8i3qNC9Svw1QgOkSkQoO0kIPI473tR9QRbo2KP88Ty9p8WbPy+3OpTaAIzehtuHq+A==}
ofetch@1.5.1:
resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==}
@@ -2732,6 +2834,9 @@ packages:
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
papaparse@5.5.3:
resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -2824,6 +2929,12 @@ packages:
peerDependencies:
react: ^19.2.3
react-hook-form@7.69.0:
resolution: {integrity: sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@15.7.4:
resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==}
peerDependencies:
@@ -2873,6 +2984,12 @@ packages:
'@types/react':
optional: true
react-resizable-panels@4.1.0:
resolution: {integrity: sha512-8ZpOwdKQz6bCs2LGnfS6HuBITxkOLelSMzBX4DrWsgHaU3ukTPxmBNAeK8Bsp3LAEdtXeG6ll6UPN7OJNua4sw==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -2926,6 +3043,9 @@ packages:
regex@6.0.1:
resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==}
regexp-to-ast@0.4.0:
resolution: {integrity: sha512-4qf/7IsIKfSNHQXSwial1IFmfM1Cc/whNBQqRwe0V2stPe7KmN1U0tWQiIx6JiirgSrisjE0eECdNf7Tav1Ntw==}
rehype-attr@3.0.3:
resolution: {integrity: sha512-Up50Xfra8tyxnkJdCzLBIBtxOcB2M1xdeKe1324U06RAvSjYm7ULSeoM+b/nYPQPVd7jsXJ9+39IG1WAJPXONw==}
engines: {node: '>=16'}
@@ -3159,6 +3279,9 @@ packages:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
tiny-emitter@2.1.0:
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
@@ -4037,6 +4160,8 @@ snapshots:
'@esbuild/win32-x64@0.25.10':
optional: true
'@handsontable/pikaday@1.0.0': {}
'@img/colour@1.0.0':
optional: true
@@ -4145,20 +4270,27 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@kevisual/api@0.0.10':
'@kevisual/api@0.0.14':
dependencies:
'@kevisual/js-filter': 0.0.2
'@kevisual/js-filter': 0.0.3
'@kevisual/load': 0.0.6
es-toolkit: 1.43.0
eventemitter3: 5.0.1
nanoid: 5.1.6
'@kevisual/cache@0.0.3':
dependencies:
idb-keyval: 6.2.2
'@kevisual/cache@0.0.5':
dependencies:
idb-keyval: 6.2.2
lru-cache: 11.2.4
nanoid: 5.1.6
'@kevisual/context@0.0.4': {}
'@kevisual/js-filter@0.0.2': {}
'@kevisual/js-filter@0.0.3': {}
'@kevisual/load@0.0.6':
dependencies:
@@ -4190,8 +4322,9 @@ snapshots:
- react-native
- typescript
'@kevisual/router@0.0.51':
'@kevisual/router@0.0.52':
dependencies:
eventemitter3: 5.0.1
path-to-regexp: 8.3.0
selfsigned: 5.4.0
send: 1.2.1
@@ -4328,6 +4461,22 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-checkbox@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)':
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-presence': 1.1.5(@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-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-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.3)':
dependencies:
react: 19.2.3
@@ -4399,6 +4548,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
'@radix-ui/react-label@2.1.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)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@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)
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-portal@1.1.9(@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/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)
@@ -4485,6 +4643,19 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
'@radix-ui/react-use-previous@1.1.1(@types/react@19.2.7)(react@19.2.3)':
dependencies:
react: 19.2.3
optionalDependencies:
'@types/react': 19.2.7
'@radix-ui/react-use-size@1.1.1(@types/react@19.2.7)(react@19.2.3)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.3)
react: 19.2.3
optionalDependencies:
'@types/react': 19.2.7
'@rc-component/async-validator@5.0.4':
dependencies:
'@babel/runtime': 7.28.4
@@ -4648,7 +4819,7 @@ 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(dayjs@1.11.19)(moment@2.30.1)(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)
@@ -4659,6 +4830,7 @@ snapshots:
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
dayjs: 1.11.19
moment: 2.30.1
'@rc-component/portal@2.0.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
@@ -5091,6 +5263,9 @@ snapshots:
dependencies:
'@types/node': 17.0.45
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@@ -5317,7 +5492,7 @@ snapshots:
ansi-styles@6.2.3: {}
antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
antd@6.1.3(moment@2.30.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@ant-design/colors': 8.0.0
'@ant-design/cssinjs': 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -5343,7 +5518,7 @@ 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(dayjs@1.11.19)(moment@2.30.1)(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)
@@ -5513,6 +5688,8 @@ snapshots:
bcp-47-match@2.0.3: {}
bignumber.js@9.3.1: {}
birpc@2.8.0: {}
boolbase@1.0.0: {}
@@ -5562,6 +5739,11 @@ snapshots:
character-reference-invalid@2.0.1: {}
chevrotain@6.5.0:
dependencies:
regexp-to-ast: 0.4.0
optional: true
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@@ -5612,6 +5794,8 @@ snapshots:
dependencies:
is-what: 5.5.0
core-js@3.47.0: {}
cross-fetch@4.0.0:
dependencies:
node-fetch: 2.7.0
@@ -5719,6 +5903,10 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
@@ -5930,6 +6118,16 @@ snapshots:
ufo: 1.6.1
uncrypto: 0.1.3
handsontable@16.2.0:
dependencies:
'@handsontable/pikaday': 1.0.0
core-js: 3.47.0
dompurify: 3.3.1
moment: 2.30.1
numbro: 2.5.0
optionalDependencies:
hyperformula: 3.1.1
hast-util-from-html@2.0.3:
dependencies:
'@types/hast': 3.0.4
@@ -6126,6 +6324,12 @@ snapshots:
human-signals@8.0.1: {}
hyperformula@3.1.1:
dependencies:
chevrotain: 6.5.0
tiny-emitter: 2.1.0
optional: true
i18next-browser-languagedetector@8.2.0:
dependencies:
'@babel/runtime': 7.28.4
@@ -6266,6 +6470,8 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.2.4: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -6743,6 +6949,8 @@ snapshots:
mitt@3.0.1: {}
moment@2.30.1: {}
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -6778,6 +6986,10 @@ snapshots:
dependencies:
boolbase: 1.0.0
numbro@2.5.0:
dependencies:
bignumber.js: 9.3.1
ofetch@1.5.1:
dependencies:
destr: 2.0.5
@@ -6820,6 +7032,8 @@ snapshots:
pako@0.2.9: {}
papaparse@5.5.3: {}
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -6910,6 +7124,10 @@ snapshots:
react: 19.2.3
scheduler: 0.27.0
react-hook-form@7.69.0(react@19.2.3):
dependencies:
react: 19.2.3
react-i18next@15.7.4(i18next@25.6.0(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.4
@@ -6960,6 +7178,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.7
react-resizable-panels@4.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.2.3):
dependencies:
get-nonce: 1.0.1
@@ -7026,6 +7249,9 @@ snapshots:
dependencies:
regex-utilities: 2.3.0
regexp-to-ast@0.4.0:
optional: true
rehype-attr@3.0.3:
dependencies:
unified: 11.0.5
@@ -7396,6 +7622,9 @@ snapshots:
throttle-debounce@5.0.2: {}
tiny-emitter@2.1.0:
optional: true
tiny-inflate@1.0.3: {}
tinyexec@1.0.2: {}

2
web/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- core-js

View File

@@ -2,9 +2,18 @@ import { toast, ToastContainer } from 'react-toastify';
import { useStudioStore } from './store.ts';
import { useEffect, useState } from 'react';
import { MonitorPlay, Play } from 'lucide-react';
import { Panel, Group } from 'react-resizable-panels'
import { ViewList } from '../view/list.tsx';
export const AppProvider = () => {
return <main className='w-full'>
<App />
return <main className='w-full h-screen flex flex-col overflow-hidden'>
<Group className="h-full flex-1 overflow-hidden">
<Panel defaultSize={300} minSize={250} maxSize={500} className="border-r overflow-auto">
<ViewList />
</Panel>
<Panel>
<App />
</Panel>
</Group>
<ToastContainer
position="top-right"
autoClose={3000}
@@ -28,12 +37,12 @@ interface RouteItem {
}
export const App = () => {
const { routes, getRoutes, run } = useStudioStore();
const { routes, getRouteList, run } = useStudioStore();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [visibleIds, setVisibleIds] = useState<Set<string>>(new Set());
useEffect(() => {
getRoutes();
getRouteList();
}, []);
const toggleDescription = (id: string) => {
@@ -58,7 +67,7 @@ export const App = () => {
};
return (
<div className="max-w-5xl mx-auto p-6">
<div className="max-w-5xl mx-auto p-6 h-full overflow-auto">
<div className="space-y-1">
{routes.map((route: RouteItem) => {
const isExpanded = expandedIds.has(route.id);

View File

@@ -1,10 +1,9 @@
import { create } from 'zustand';
import { QueryProxy, ProxyItem } from '@kevisual/api'
// import { query } from '@/modules/query.ts'
import { QueryClient } from '@kevisual/query';
import { QueryProxy, RouterViewData } from '@kevisual/api'
import { query } from '@/modules/query.ts'
import { toast } from 'react-toastify';
import { QueryRouterServer } from '@kevisual/router/src/route.ts'
import { use } from '@kevisual/context'
import { MyCache } from '@kevisual/cache'
type RouteItem = {
id: string;
@@ -13,58 +12,81 @@ type RouteItem = {
description?: string;
metadata?: Record<string, any>;
}
// type ProxyItem = {
// title?: string;
// type?: 'api' | 'context' | 'page';
// description?: string;
// api?: {
// url: string;
// },
// context?: {
// key: string;
// },
// page?: {},
// where?: string;
// whereList?: Array<{ title: string; where: string }>;
// }
type RouteViewList = Array<RouterViewData>;
interface StudioState {
routes: Array<RouteItem>;
getRoutes: () => Promise<void>;
getRouteList: () => Promise<void>;
run: (route: RouteItem) => Promise<void>;
queryProxy?: QueryProxy;
router?: QueryRouterServer;
init: (opts?: { url?: string }) => Promise<{ router: QueryRouterServer; queryProxy: QueryProxy }>;
proxy?: ProxyItem;
setProxy?: (proxy: ProxyItem) => void;
proxyList?: ProxyItem[];
setProxyList?: (list: ProxyItem[]) => void;
init: (force?: boolean) => Promise<{ queryProxy: QueryProxy }>;
routeViewList: RouteViewList;
getViewList: () => Promise<void>;
getCurrentView: () => Promise<void>;
updateRouteView: (view: RouterViewData) => Promise<void>;
deleteRouteView: (id: string) => Promise<void>;
currentView?: RouterViewData;
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const useStudioStore = create<StudioState>((set, get) => ({
routes: [],
getRoutes: async () => {
const state = get();
getRouteList: async () => {
await get().getCurrentView();
const state = await get().init();
let queryProxy = state.queryProxy;
if (!queryProxy) {
const init = await state.init();
queryProxy = init.queryProxy;
}
console.log('query proxy', queryProxy.router);
const routes: any[] = await queryProxy.listRoutes(() => true, "")
console.log('fetched routes', routes);
const url = new URL(window.location.href);
const viewId = url.searchParams.get('viewId') || 'default';
const routes: any[] = await queryProxy.listRoutes(() => true, { viewId });
set({ routes });
get().getViewList();
},
getViewList: async () => {
const res = await query.post({ path: 'views', key: 'list' });
if (res.code === 200) {
const list = res.data.list as RouteViewList || [];
set({ routeViewList: list });
}
},
getCurrentView: async () => {
const url = new URL(window.location.href);
const viewId = url.searchParams.get('viewId');
if (!viewId) {
return;
}
const res = await query.post({ path: 'views', key: 'current', data: { viewId: viewId } });
if (res.code === 200) {
const view = res.data as RouterViewData;
set({ currentView: view });
} else {
set({ currentView: undefined });
}
},
routeViewList: [],
updateRouteView: async (view: RouterViewData) => {
const res = await query.post({ path: 'views', key: 'update', data: view });
if (res.code !== 200) {
toast.error(`视图更新失败:${res.message || '未知错误'}`);
return;
} else {
get().getViewList();
toast.success('视图更新成功');
}
},
deleteRouteView: async (id: string) => {
const res = await query.post({ path: 'views', key: 'delete', data: { id } });
if (res.code !== 200) {
toast.error(`视图删除失败:${res.message || '未知错误'}`);
return;
}
get().getViewList();
toast.success('视图删除成功');
},
run: async (route: RouteItem) => {
const state = get();
let queryProxy = state.queryProxy!;
if (!state.queryProxy) {
const init = await state.init();
queryProxy = init.queryProxy;
}
console.log('running route', route, queryProxy.query.url);
const state = await get().init();
let queryProxy = state.queryProxy;
const res = await queryProxy.run({ path: route.path, key: route.key });
console.log('route run result', res);
if (res.code !== 200) {
toast.error(`运行失败:${res.message || '未知错误'}`);
} else if (res.code === 200) {
@@ -73,51 +95,38 @@ export const useStudioStore = create<StudioState>((set, get) => ({
},
queryProxy: undefined,
router: undefined,
init: async () => {
const proxy = get().proxy || localStorageProxy.get();
const url = proxy.type === 'api' && proxy.api ? proxy.api.url : '/client/router';
init: async (force?: boolean) => {
// let _url = 'http://localhost:52002/api/router';
let _url = 'http://localhost:52000/api/router';
// let _url = '/api/router';
const query = new QueryClient({
url: _url,
let queryProxy = get().queryProxy;
if (queryProxy && !force) {
return { queryProxy };
}
let currentView: RouterViewData | undefined = get().currentView;
console.log('currentView in init', currentView);
const routerViewData: RouterViewData = currentView || {
views: [{
id: 'default',
title: '默认视图',
query: `WHERE path = 'file' `
}],
data: {
items: []
},
viewId: 'default',
}
console.log('initializing query proxy with view', routerViewData);
queryProxy = new QueryProxy({
routerViewData
});
const router = new QueryRouterServer();
const queryProxy = new QueryProxy({ query, router });
await queryProxy.init();
set({ queryProxy, router });
return { router, queryProxy }
},
proxy: undefined,
setProxy: (proxy: ProxyItem) => {
localStorageProxy.set(proxy);
set({ proxy });
},
proxyList: [],
setProxyList: (list: ProxyItem[]) => {
set({ proxyList: list });
await sleep(500);
set({ queryProxy });
return { queryProxy }
},
}));
export const localStorageProxy = {
get: (): ProxyItem => {
const data = localStorage.getItem('PROXY_CONFIG')
if (data) {
return JSON.parse(data)
}
const defult: ProxyItem = {
title: '默认',
description: '默认',
type: 'api',
api: {
url: '/client/router'
},
}
localStorageProxy.set(defult)
return defult;
},
set: (proxy: ProxyItem) => {
localStorage.setItem('PROXY_CONFIG', JSON.stringify(proxy))
}
}
use('studioStore', () => {
return useStudioStore.getState();
});

View File

@@ -0,0 +1,200 @@
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
import { Query } from "@kevisual/query"
import { QueryRouterServer } from "@kevisual/router"
import { nanoid } from "nanoid"
export type RouterViewItem = RouterViewApi | RouterViewContext | RouterViewWorker;
type RouteViewBase = {
id: string;
title: string;
description: string;
enabled?: boolean;
}
export type RouterViewApi = {
type: 'api',
api: {
url: string,
// 已初始化的query实例不需要编辑配置
query?: Query
}
} & RouteViewBase;
export type RouterViewContext = {
type: 'context',
context: {
key: string,
// 从context中获取router不需要编辑配置
router?: QueryRouterServer
}
} & RouteViewBase;
export type RouterViewWorker = {
type: 'worker',
worker: {
type: 'Worker' | 'SharedWorker' | 'serviceWorker',
url: string,
// 已初始化的worker实例不需要编辑配置
worker?: Worker | SharedWorker | ServiceWorker,
/**
* worker选项
* default: { type: 'module' }
*/
workerOptions?: {
type: 'module' | 'classic'
}
}
} & RouteViewBase;
interface DataItemFormProps {
item: RouterViewItem
onChange: (item: any) => void
onRemove: () => void
}
export const DataItemForm = ({ item, onChange, onRemove }: DataItemFormProps) => {
const handleChange = (field: string, value: any) => {
if (field === 'type') {
const newItem: RouterViewItem = { ...item, type: value }
if (value === 'api' && !('api' in item)) {
(newItem as RouterViewApi).api = { url: '' }
} else if (value === 'context' && !('context' in item)) {
(newItem as RouterViewContext).context = { key: '' }
} else if (value === 'worker' && !('worker' in item)) {
(newItem as RouterViewWorker).worker = { type: 'Worker', url: '', workerOptions: { type: 'module' } }
}
if (!newItem.id) {
newItem.id = nanoid(16)
}
onChange(newItem)
} else {
onChange({ ...item, [field]: value })
}
}
const handleNestedChange = (parent: string, field: string, value: any) => {
const parentValue = item[parent as keyof RouterViewItem] as Record<string, any> | undefined
const newParentValue: Record<string, any> = {
...(parentValue || {}),
[field]: value
}
onChange({ ...item, [parent]: newParentValue })
}
const handleNestedDeepChange = (parent: string, nestedParent: string, field: string, value: any) => {
const parentValue = item[parent as keyof RouterViewItem] as Record<string, any> | undefined
const nestedValue = parentValue?.[nestedParent] as Record<string, any> | undefined
const newNestedValue: Record<string, any> = {
...(nestedValue || {}),
[field]: value
}
const newParentValue: Record<string, any> = {
...(parentValue || {}),
[nestedParent]: newNestedValue
}
onChange({ ...item, [parent]: newParentValue })
}
return (
<div className="border rounded-lg p-4 mb-4 space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-medium"></h3>
<button
type="button"
onClick={onRemove}
className="text-sm text-red-500 hover:text-red-700"
>
</button>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={item.title || ''}
onChange={(e) => handleChange('title', e.target.value)}
placeholder="输入标题"
/>
</div>
<div className="space-y-2">
<Label></Label>
<select
value={item.type}
onChange={(e) => handleChange('type', e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="api">API</option>
<option value="context">Context</option>
<option value="worker">Worker</option>
</select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="enabled"
checked={item.enabled !== false}
onCheckedChange={(checked) => handleChange('enabled', checked)}
/>
<Label htmlFor="enabled" className="cursor-pointer"></Label>
</div>
{(item.type === 'api') && (
<div className="space-y-2">
<Label>API URL</Label>
<Input
value={item.api?.url || ''}
onChange={(e) => handleNestedChange('api', 'url', e.target.value)}
placeholder="输入 API 地址"
/>
</div>
)}
{item.type === 'context' && (
<div className="space-y-2">
<Label>Context Key</Label>
<Input
value={item.context?.key || ''}
onChange={(e) => handleNestedChange('context', 'key', e.target.value)}
placeholder="输入 Context Key"
/>
</div>
)}
{item.type === 'worker' && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Worker Type</Label>
<select
value={item.worker?.type || 'Worker'}
onChange={(e) => handleNestedChange('worker', 'type', e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="Worker">Worker</option>
<option value="SharedWorker">SharedWorker</option>
<option value="serviceWorker">ServiceWorker</option>
</select>
</div>
<div className="space-y-2">
<Label>Worker URL</Label>
<Input
value={item.worker?.url || ''}
onChange={(e) => handleNestedChange('worker', 'url', e.target.value)}
placeholder="输入 Worker URL"
/>
</div>
<div className="space-y-2">
<Label>Worker Options Type</Label>
<select
value={item.worker?.workerOptions?.type || 'module'}
onChange={(e) => handleNestedDeepChange('worker', 'workerOptions', 'type', e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="module">Module</option>
<option value="classic">Classic</option>
</select>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,146 @@
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DataItemForm } from "@/apps/view/components/DataItemForm"
import { ViewFormItem } from "@/apps/view/components/ViewFormItem"
import { nanoid } from "nanoid"
interface ViewEditorProps {
open: boolean
onOpenChange: (open: boolean) => void
data?: {
id?: string
title?: string
data?: { items: any[] }
views?: any[]
}
onSave: (data: any) => void
}
export const ViewEditor = ({ open, onOpenChange, data, onSave }: ViewEditorProps) => {
const [title, setTitle] = useState('')
const [dataItems, setDataItems] = useState<any[]>([])
const [views, setViews] = useState<any[]>([])
const isUpdate = !!data?.id
useEffect(() => {
if (open) {
setTitle(data?.title || '')
setDataItems(data?.data?.items || [])
setViews(data?.views || [])
}
}, [open, data])
const handleAddDataItem = () => {
setDataItems([...dataItems, { type: 'api', api: { url: '' } }])
}
const handleUpdateDataItem = (index: number, item: any) => {
const newItems = [...dataItems]
newItems[index] = item
setDataItems(newItems)
}
const handleRemoveDataItem = (index: number) => {
setDataItems(dataItems.filter((_, i) => i !== index))
}
const handleAddView = () => {
setViews([...views, { id: nanoid(16), title: '', query: '' }])
}
const handleUpdateView = (index: number, view: any) => {
const newViews = [...views]
newViews[index] = view
setViews(newViews)
}
const handleRemoveView = (index: number) => {
setViews(views.filter((_, i) => i !== index))
}
const handleSave = () => {
const viewData = {
id: data?.id,
title,
data: {
items: dataItems
},
views
}
onSave(viewData)
onOpenChange(false)
}
const handleClose = () => {
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isUpdate ? '编辑视图' : '新增视图'}</DialogTitle>
</DialogHeader>
<div className="space-y-6 max-h-[70vh] overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="title"></Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="输入视图标题"
/>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-medium"> (data.items)</h3>
<Button type="button" variant="outline" size="sm" onClick={handleAddDataItem}>
</Button>
</div>
{dataItems.map((item, index) => (
<DataItemForm
key={index}
item={item}
onChange={(newItem) => handleUpdateDataItem(index, newItem)}
onRemove={() => handleRemoveDataItem(index)}
/>
))}
</div>
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-medium"> (views)</h3>
<Button type="button" variant="outline" size="sm" onClick={handleAddView}>
</Button>
</div>
{views.map((view, index) => (
<ViewFormItem
key={view.id || index}
view={view}
onChange={(newView) => handleUpdateView(index, newView)}
onRemove={() => handleRemoveView(index)}
/>
))}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
</Button>
<Button type="button" onClick={handleSave}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,58 @@
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
interface ViewFormProps {
view: any
onChange: (view: any) => void
onRemove: () => void
}
export const ViewFormItem = ({ view, onChange, onRemove }: ViewFormProps) => {
const handleChange = (field: string, value: any) => {
onChange({ ...view, [field]: value })
}
return (
<div className="border rounded-lg p-4 mb-4 space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-medium"></h3>
<button
type="button"
onClick={onRemove}
className="text-sm text-red-500 hover:text-red-700"
>
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>ID</Label>
<Input
value={view.id || ''}
onChange={(e) => handleChange('id', e.target.value)}
placeholder="自动生成"
disabled
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={view.title || ''}
onChange={(e) => handleChange('title', e.target.value)}
placeholder="输入视图标题"
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={view.query || ''}
onChange={(e) => handleChange('query', e.target.value)}
placeholder="输入查询语句"
/>
</div>
</div>
)
}

10
web/src/apps/view/form.ts Normal file
View File

@@ -0,0 +1,10 @@
import { useForm } from 'react-hook-form';
import { RouterViewQuery, RouterViewItem } from '@kevisual/api';
type ViewFormData = {
id?: string;
title: string;
data?: {
items: RouterViewItem[];
},
views: RouterViewQuery[];
};

129
web/src/apps/view/list.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { useState } from "react";
import { useStudioStore } from '../studio/store.ts';
import { Search, RotateCw, Plus, MoreHorizontal, Layout, Edit2, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ViewEditor } from "@/apps/view/components/ViewEditor.tsx";
export const ViewList = () => {
const { routeViewList, updateRouteView, deleteRouteView } = useStudioStore();
const [selectedItems, setSelectedItems] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [editingView, setEditingView] = useState<any>(null);
const filteredViews = routeViewList.filter(view =>
(view.title || '未命名视图').toLowerCase().includes(searchTerm.toLowerCase()) ||
(view.description || '').toLowerCase().includes(searchTerm.toLowerCase())
);
const handleRefresh = () => {
// 刷新逻辑
};
const handleAdd = () => {
handleEdit({});
};
const handleEdit = (view: any) => {
setEditingView(view);
setEditorOpen(true);
};
const handleDelete = (id: string) => {
if (confirm('确定要删除这个视图吗?')) {
deleteRouteView(id);
}
};
const handleSaveView = (viewData: any) => {
updateRouteView(viewData);
};
return (
<div className="w-full h-full max-w-4xl p-4 border rounded-md shadow-sm">
<div className="flex items-center space-x-2 mb-4">
<div className="relative flex-1">
<Input
placeholder="搜索视图..."
className="pl-3 pr-8"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
</div>
<Button variant="outline" size="icon" className="h-10 w-10" onClick={handleRefresh}>
<RotateCw className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-10 w-10" onClick={handleAdd}>
<Plus className="h-4 w-4" />
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-medium"></TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredViews.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-4 text-gray-500">
{searchTerm ? '未找到匹配的视图' : '暂无视图'}
</TableCell>
</TableRow>
) : (
filteredViews.map((view) => (
<TableRow key={view.id} className={selectedItems.includes(view.id) ? "bg-gray-100" : ""}>
<TableCell className="font-medium">
<div className="flex items-center">
<Layout className="h-4 w-4 mr-2 text-gray-500" />
{view.title || '未命名视图'}
</div>
</TableCell>
<TableCell>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(view)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-700"
onClick={() => handleDelete(view.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<ViewEditor
open={editorOpen}
onOpenChange={setEditorOpen}
data={editingView}
onSave={handleSaveView}
/>
</div>
);
}

View File

@@ -38,8 +38,8 @@ const buttonVariants = cva(
function Button({
className,
variant,
size,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
@@ -51,6 +51,8 @@ function Button({
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const Dialog = ({ open, onOpenChange, children }: { open: boolean; onOpenChange: (open: boolean) => void; children: React.ReactNode }) => {
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={() => onOpenChange(false)} />
<div className="relative z-50 w-full max-w-2xl max-h-[90vh] overflow-auto bg-white rounded-lg shadow-lg p-6">
{children}
</div>
</div>
)
}
const DialogHeader = ({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left mb-4", className)} {...props}>
{children}
</div>
)
const DialogTitle = ({ className, children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props}>
{children}
</h2>
)
const DialogContent = ({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("", className)} {...props}>
{children}
</div>
)
const DialogFooter = ({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-4", className)} {...props}>
{children}
</div>
)
export { Dialog, DialogHeader, DialogTitle, DialogContent, DialogFooter }

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-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(
"bg-muted/50 border-t 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(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -1,4 +1,4 @@
import { Query } from '@kevisual/query'
import { QueryClient } from '@kevisual/query'
const getUrl = () => {
const host = window.location.host
@@ -10,6 +10,10 @@ const getUrl = () => {
return '/client/router'
}
export const query = new Query({
url: getUrl()
export const query = new QueryClient({
url: '/api/router',
});
export const queryClient = new QueryClient({
url: getUrl(),
});