generated from kevisual/vite-react-template
Compare commits
27 Commits
ee176fd80b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
518a3f2864 | ||
|
|
477826dcce | ||
|
|
ef08303182 | ||
|
|
389f7a7ad2 | ||
|
|
dd6eff9269 | ||
|
|
9a06364880 | ||
|
|
3f0733a540 | ||
|
|
330accb822 | ||
|
|
469d23b0b9 | ||
|
|
bc9ce9e5df | ||
|
|
0b08b82356 | ||
|
|
3a821b1486 | ||
|
|
81b52cce8c | ||
|
|
500ceb2e42 | ||
| a40cc2175e | |||
|
|
f74d5a4510 | ||
| 4ed81a1c68 | |||
| 253ef2ac7d | |||
| 6c914c3186 | |||
| eebdaec389 | |||
| 575feec78b | |||
|
|
d196b24d07 | ||
|
|
20edf1893e | ||
| 0a0acf1fe7 | |||
| 364e903d5f | |||
| f05aab2430 | |||
| 58563ece93 |
28
.cnb.yml
28
.cnb.yml
@@ -4,8 +4,7 @@ include:
|
|||||||
|
|
||||||
.common_env: &common_env
|
.common_env: &common_env
|
||||||
env:
|
env:
|
||||||
TO_REPO: kevisual/cnb-center
|
USERNAME: root
|
||||||
TO_URL: git.xiongxiao.me
|
|
||||||
imports:
|
imports:
|
||||||
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
|
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
|
||||||
|
|
||||||
@@ -16,29 +15,6 @@ $:
|
|||||||
services:
|
services:
|
||||||
- vscode
|
- vscode
|
||||||
- docker
|
- docker
|
||||||
|
env: !reference [.common_env, env]
|
||||||
imports: !reference [.common_env, imports]
|
imports: !reference [.common_env, imports]
|
||||||
# 开发环境启动后会执行的任务
|
|
||||||
# stages:
|
|
||||||
# - name: pnpm install
|
|
||||||
# script: pnpm install
|
|
||||||
stages: !reference [.dev_template, stages]
|
stages: !reference [.dev_template, stages]
|
||||||
|
|
||||||
.common_sync_to_gitea: &common_sync_to_gitea
|
|
||||||
- <<: *common_env
|
|
||||||
services: !reference [.common_sync_to_gitea_template, services]
|
|
||||||
stages: !reference [.common_sync_to_gitea_template, stages]
|
|
||||||
|
|
||||||
.common_sync_from_gitea: &common_sync_from_gitea
|
|
||||||
- <<: *common_env
|
|
||||||
services: !reference [.common_sync_from_gitea_template, services]
|
|
||||||
stages: !reference [.common_sync_from_gitea_template, stages]
|
|
||||||
|
|
||||||
main:
|
|
||||||
web_trigger_sync_to_gitea:
|
|
||||||
- <<: *common_sync_to_gitea
|
|
||||||
web_trigger_sync_from_gitea:
|
|
||||||
- <<: *common_sync_from_gitea
|
|
||||||
api_trigger_sync_to_gitea:
|
|
||||||
- <<: *common_sync_to_gitea
|
|
||||||
api_trigger_sync_from_gitea:
|
|
||||||
- <<: *common_sync_from_gitea
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
## 本地环境
|
## 本地环境
|
||||||
|
|
||||||
# VITE_API_URL = "http://localhost:8000"
|
# VITE_API_URL="http://localhost:8000"
|
||||||
### 开发环境
|
### 开发环境
|
||||||
VITE_API_URL = "https://kevisual.xiongxiao.me"
|
VITE_API_URL="https://kevisual.xiongxiao.me"
|
||||||
### 生产环境
|
### 生产环境
|
||||||
# VITE_API_URL = "https://kevisual.cn"
|
# VITE_API_UR="https://kevisual.cn"
|
||||||
|
|||||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -1,37 +1,19 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
.env
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
pack-dist
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
tsconfig.app.tsbuildinfo
|
|
||||||
tsconfig.node.tsbuildinfo
|
|
||||||
|
|
||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
|
||||||
.tanstack
|
.tanstack
|
||||||
.env
|
.env*
|
||||||
|
|
||||||
!.env.example
|
!.env.example
|
||||||
|
.pnpm-lock.yaml
|
||||||
23
AGENTS.md
23
AGENTS.md
@@ -25,19 +25,36 @@ src/
|
|||||||
```
|
```
|
||||||
pages/page-app/
|
pages/page-app/
|
||||||
├── components/ # 模块专属组件
|
├── components/ # 模块专属组件
|
||||||
├── store/ # 模块状态管理
|
├── hooks/ # 模块 React Query hooks(API 查询封装)
|
||||||
└── module/ # 模块功能函数
|
├── modules/ # 模块功能函数(UI 组件、工具函数等)
|
||||||
|
└── store/ # 模块状态管理(Zustand)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### hooks/ 文件夹说明
|
||||||
|
|
||||||
|
每个模块的 `hooks/` 文件夹用于封装与该模块相关的 React Query hooks:
|
||||||
|
|
||||||
|
- **use-api-query.ts**: 使用 `@tanstack/react-query` 的 `useQuery` 封装 API 调用
|
||||||
|
- 定义 `queryKeys` 常量用于缓存标识
|
||||||
|
- 封装 `useQuery` hooks 用于数据获取(GET 请求)
|
||||||
|
- 封装 `useMutation` hooks 用于数据修改(POST/PUT/DELETE 请求)
|
||||||
|
- 支持预取(prefetch)和无限滚动(infinite query)
|
||||||
|
- **index.ts**: 导出模块所有 hooks,便于统一导入使用
|
||||||
|
|
||||||
### 状态和数据获取
|
### 状态和数据获取
|
||||||
|
|
||||||
|
- **@tanstack/react-query** 用于数据获取、缓存和状态管理
|
||||||
|
- 在模块的 `hooks/` 文件夹中封装 API 调用
|
||||||
|
- QueryClient 实例位于 `src/modules/query.ts`
|
||||||
|
- 在 `src/routes/__root.tsx` 中通过 `QueryClientProvider` 提供
|
||||||
- **Zustand** 用于全局状态管理
|
- **Zustand** 用于全局状态管理
|
||||||
- **@kevisual/query** 用于数据获取(QueryClient 实例位于 `src/modules/query.ts`)
|
- **@kevisual/query** 用于底层 API 请求封装
|
||||||
- **React Hook Form** 用于表单管理
|
- **React Hook Form** 用于表单管理
|
||||||
|
|
||||||
## 核心依赖
|
## 核心依赖
|
||||||
|
|
||||||
- **@base-ui/react**: Headless UI 基础组件
|
- **@base-ui/react**: Headless UI 基础组件
|
||||||
|
- **@tanstack/react-query**: 数据获取、缓存和状态管理(配合 hooks/ 使用)
|
||||||
- **@tanstack/react-router**: 基于 TanStack Router 插件的文件路由
|
- **@tanstack/react-router**: 基于 TanStack Router 插件的文件路由
|
||||||
- **class-variance-authority**: 基于变体的样式系统
|
- **class-variance-authority**: 基于变体的样式系统
|
||||||
- **clsx + tailwind-merge**: 通过 `cn()` 提供 className 工具函数
|
- **clsx + tailwind-merge**: 通过 `cn()` 提供 className 工具函数
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -1,26 +1,12 @@
|
|||||||
# cnb center
|
# cnb center
|
||||||
|
|
||||||
> cnb 仓库界面很不好用,所以自己写了一个纯调api的界面,方便管理仓库和查看同步状态
|
一个应用工作台
|
||||||
|
|
||||||
## 主要功能
|
## 功能
|
||||||
|
|
||||||
### git仓库管理功能
|
1. 对话管理
|
||||||
|
2. 仓库管理
|
||||||
- 创建仓库
|
3. 云开发(cloud-dev)
|
||||||
- 删除仓库(使用cookie)
|
4. 应用管理
|
||||||
- 同步仓库
|
5. Agent管理
|
||||||
|
6. 配置
|
||||||
仓库列出用户所有仓库,主体显示仓库名,描述。每一个仓库具备更快捷的功能模块。
|
|
||||||
|
|
||||||
启动是启动云开发环境。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
配置项
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
示例cookie配置
|
|
||||||
```sh
|
|
||||||
CNBSESSION=1770622649.1935321989751226368.0bc7fc786f7052cb2b077c00ded651a5945d46d1e466f4fafa14ede554da14a0;csrfkey=158893308
|
|
||||||
```
|
|
||||||
21
kevisual.json
Normal file
21
kevisual.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "kevisual",
|
||||||
|
"share": "public"
|
||||||
|
},
|
||||||
|
"registry": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template",
|
||||||
|
"clone": {
|
||||||
|
".": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syncd": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"registry": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sync": {}
|
||||||
|
}
|
||||||
60
package.json
60
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@kevisual/cnb-center",
|
"name": "@kevisual/cnb-center",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.6",
|
"version": "0.0.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"basename": "/root/cnb-center",
|
"basename": "/root/cnb-center",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"ui": "pnpm dlx shadcn@latest add ",
|
"ui": "pnpm dlx shadcn@latest add ",
|
||||||
"pub": "envision deploy ./dist -k cnb-center -v 0.0.6 -y y -u"
|
"pub": "envision deploy ./dist -k cnb-center -v 0.0.8 -y y -u"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
@@ -17,34 +17,38 @@
|
|||||||
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.45",
|
"@ai-sdk/anthropic": "^3.0.58",
|
||||||
"@ai-sdk/openai": "^3.0.30",
|
"@ai-sdk/openai": "^3.0.41",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.35",
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
"@kevisual/api": "^0.0.60",
|
"@kevisual/api": "^0.0.64",
|
||||||
"@kevisual/cnb": "^0.0.26",
|
"@kevisual/cnb": "^0.0.52",
|
||||||
"@kevisual/cnb-ai": "^0.0.2",
|
"@kevisual/cnb-ai": "^0.0.2",
|
||||||
"@kevisual/context": "^0.0.8",
|
"@kevisual/context": "^0.0.8",
|
||||||
"@kevisual/kv-login": "^0.1.15",
|
"@kevisual/kv-login": "^0.1.18",
|
||||||
"@kevisual/router": "0.0.80",
|
"@kevisual/router": "0.1.3",
|
||||||
"@tanstack/react-router": "^1.161.1",
|
"@tanstack/react-router": "^1.167.4",
|
||||||
"@uiw/react-codemirror": "^4.25.5",
|
"@uiw/react-codemirror": "^4.25.8",
|
||||||
"ai": "^6.0.91",
|
"ai": "^6.0.116",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.19",
|
"cmdk": "^1.1.1",
|
||||||
"es-toolkit": "^1.44.0",
|
"dayjs": "^1.11.20",
|
||||||
|
"es-toolkit": "^1.45.1",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.577.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"re-resizable": "^6.11.2",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.2",
|
||||||
|
"react-resizable": "^3.1.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
@@ -52,20 +56,22 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@kevisual/gitea": "^0.0.6",
|
"@kevisual/gitea": "^0.0.6",
|
||||||
"@kevisual/query": "0.0.49",
|
"@kevisual/query": "0.0.53",
|
||||||
"@kevisual/types": "^0.0.12",
|
"@kevisual/types": "^0.0.12",
|
||||||
"@tailwindcss/vite": "^4.2.0",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-router-devtools": "^1.161.1",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/router-plugin": "^1.161.1",
|
"@tanstack/react-router-devtools": "^1.166.9",
|
||||||
"@types/node": "^25.3.0",
|
"@tanstack/router-plugin": "^1.166.13",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@types/react-resizable": "^3.0.8",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.0",
|
"tailwindcss": "^4.2.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.0.0-beta.14"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1004
pnpm-lock.yaml
generated
1004
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
44
public/auth.json
Normal file
44
public/auth.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "kevisual",
|
||||||
|
"share": "public"
|
||||||
|
},
|
||||||
|
"registry": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template",
|
||||||
|
"clone": {
|
||||||
|
".": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syncd": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"registry": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"auth": "ev sync clone -l -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json"
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"AGENTS.md": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/AGENTS.md",
|
||||||
|
"vite.config.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/vite.config.ts",
|
||||||
|
"src/main.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/main.tsx",
|
||||||
|
"public/auth.json": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json",
|
||||||
|
"src/agents/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/agents/index.ts",
|
||||||
|
"src/modules/basename.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/modules/basename.ts",
|
||||||
|
"src/modules/query.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/modules/query.ts",
|
||||||
|
"src/routes/demo.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/demo.tsx",
|
||||||
|
"src/routes/index.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/index.tsx",
|
||||||
|
"src/routes/login.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/login.tsx",
|
||||||
|
"src/styles/theme.css": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/styles/theme.css",
|
||||||
|
"src/pages/auth/index.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/index.tsx",
|
||||||
|
"src/pages/auth/page.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/page.tsx",
|
||||||
|
"src/pages/auth/store.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/store.ts",
|
||||||
|
"src/pages/demo/page.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/demo/page.tsx",
|
||||||
|
"src/pages/auth/hooks/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/hooks/index.ts",
|
||||||
|
"src/pages/auth/hooks/use-api-query.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/hooks/use-api-query.ts",
|
||||||
|
"src/pages/auth/modules/BaseHeader.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/modules/BaseHeader.tsx",
|
||||||
|
"src/pages/demo/store/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/demo/store/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
|||||||
@@ -1,43 +1,10 @@
|
|||||||
import { QueryRouterServer } from '@kevisual/router/browser'
|
import { QueryRouterServer } from '@kevisual/router/browser'
|
||||||
|
|
||||||
import { useContextKey } from '@kevisual/context'
|
import { useContextKey } from '@kevisual/context'
|
||||||
import { useConfigStore } from '@/pages/config/store'
|
|
||||||
import { useGiteaConfigStore } from '@/pages/config/gitea/store'
|
|
||||||
import { CNB } from '@kevisual/cnb'
|
|
||||||
import { Gitea } from '@kevisual/gitea';
|
|
||||||
export const app = useContextKey('app', new QueryRouterServer())
|
export const app = useContextKey('app', new QueryRouterServer())
|
||||||
|
|
||||||
export const cnb: CNB = useContextKey('cnb', () => {
|
|
||||||
const state = useConfigStore.getState()
|
|
||||||
const config = state.config || {}
|
|
||||||
const cors: any = {}
|
|
||||||
if (config.ENABLE_CORS) {
|
|
||||||
cors.baseUrl = config.CNB_CORS_URL || 'https://cors.kevisual.cn'
|
|
||||||
}
|
|
||||||
console.log('state', state)
|
|
||||||
// if(state.config.)
|
|
||||||
return new CNB({
|
|
||||||
token: config.CNB_API_KEY,
|
|
||||||
cookie: config.CNB_COOKIE,
|
|
||||||
cors
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// import '@kevisual/cnb-ai'
|
// import '@kevisual/cnb-ai'
|
||||||
|
|
||||||
const url = 'https://kevisual.cn/root/cnb-ai/dist/app.js'
|
const url = 'https://kevisual.cn/root/cnb-ai/dist/app.js'
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
import(/* @vite-ignore */url)
|
import(/* @vite-ignore */url)
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
|
||||||
export const gitea = useContextKey('gitea', () => {
|
|
||||||
const state = useGiteaConfigStore.getState()
|
|
||||||
const config = state.config || {}
|
|
||||||
return new Gitea({
|
|
||||||
token: config.GITEA_TOKEN,
|
|
||||||
baseURL: config.GITEA_URL,
|
|
||||||
cors: {
|
|
||||||
baseUrl: 'https://cors.kevisual.cn'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
1
src/agents/index.ts
Normal file
1
src/agents/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './app.ts'
|
||||||
281
src/components/a/Sidebar.tsx
Normal file
281
src/components/a/Sidebar.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
'use client'
|
||||||
|
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'
|
||||||
|
import { Resizable } from 're-resizable'
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
isDeveloping?: boolean
|
||||||
|
badge?: string
|
||||||
|
hidden?: boolean
|
||||||
|
children?: NavItem[]
|
||||||
|
external?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
items: NavItem[]
|
||||||
|
className?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
logo?: React.ReactNode
|
||||||
|
title?: React.ReactNode
|
||||||
|
defaultCollapsed?: boolean
|
||||||
|
defaultWidth?: number
|
||||||
|
minWidth?: number
|
||||||
|
maxWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
items,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
logo,
|
||||||
|
title,
|
||||||
|
defaultCollapsed = false,
|
||||||
|
defaultWidth = 208,
|
||||||
|
minWidth = 120,
|
||||||
|
maxWidth = 400,
|
||||||
|
}: SidebarProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const currentPath = location.pathname
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(defaultWidth)
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||||
|
const [developingDialog, setDevelopingDialog] = useState<{ open: boolean; title: string }>({
|
||||||
|
open: false,
|
||||||
|
title: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleGroup = (path: string) => {
|
||||||
|
const newExpanded = new Set(expandedGroups)
|
||||||
|
if (newExpanded.has(path)) {
|
||||||
|
newExpanded.delete(path)
|
||||||
|
} else {
|
||||||
|
newExpanded.add(path)
|
||||||
|
}
|
||||||
|
setExpandedGroups(newExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNavClick = (item: NavItem) => {
|
||||||
|
// 优先执行 onClick 回调
|
||||||
|
if (item.onClick) {
|
||||||
|
item.onClick()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.isDeveloping) {
|
||||||
|
setDevelopingDialog({ open: true, title: item.title })
|
||||||
|
} else if (item.external && item.path.startsWith('http')) {
|
||||||
|
window.open(item.path, '_blank')
|
||||||
|
} else if (item.path.startsWith('/')) {
|
||||||
|
navigate({ to: item.path })
|
||||||
|
} else {
|
||||||
|
navigate({ href: item.path })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断当前路径是否激活(以导航路径开头)
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
if (path === '/') {
|
||||||
|
return currentPath === '/'
|
||||||
|
}
|
||||||
|
return currentPath.startsWith(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染导航项
|
||||||
|
const renderNavItem = (item: NavItem, isChild = false) => {
|
||||||
|
if (item.hidden) return null
|
||||||
|
|
||||||
|
const hasChildren = item.children && item.children.length > 0
|
||||||
|
const isExpanded = expandedGroups.has(item.path)
|
||||||
|
const active = isActive(item.path)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.path} className='list-none'>
|
||||||
|
{hasChildren ? (
|
||||||
|
// 父菜单项(可展开)
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroup(item.path)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors cursor-pointer',
|
||||||
|
active
|
||||||
|
? 'bg-gray-200 text-gray-900 font-medium'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900',
|
||||||
|
collapsed && 'justify-center px-2'
|
||||||
|
)}
|
||||||
|
title={collapsed ? item.title : undefined}
|
||||||
|
>
|
||||||
|
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-left">{item.title}</span>
|
||||||
|
<ChevronDown className={cn('w-4 h-4 transition-transform', isExpanded && 'rotate-180')} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* 子菜单 */}
|
||||||
|
{!collapsed && isExpanded && item.children && (
|
||||||
|
<ul className="ml-6 mt-1 space-y-1 list-none">
|
||||||
|
{item.children.map(child => renderNavItem(child, true))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 普通菜单项
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavClick(item)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors cursor-pointer',
|
||||||
|
active
|
||||||
|
? 'bg-gray-200 text-gray-900 font-medium'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900',
|
||||||
|
item.isDeveloping && 'opacity-60',
|
||||||
|
isChild && 'py-1.5',
|
||||||
|
collapsed && 'justify-center px-2'
|
||||||
|
)}
|
||||||
|
title={collapsed ? item.title : undefined}
|
||||||
|
>
|
||||||
|
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-left">{item.title}</span>
|
||||||
|
{item.isDeveloping && (
|
||||||
|
<span className="text-xs bg-gray-200 text-gray-600 px-1.5 py-0.5 rounded">
|
||||||
|
dev
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.badge && !item.isDeveloping && (
|
||||||
|
<span className="text-xs bg-gray-200 text-gray-700 px-1.5 py-0.5 rounded">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={cn('flex h-full', className)}>
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
{!collapsed ? (
|
||||||
|
<Resizable
|
||||||
|
defaultSize={{ width: sidebarWidth, height: '100%' }}
|
||||||
|
minWidth={minWidth}
|
||||||
|
maxWidth={maxWidth}
|
||||||
|
onResizeStop={(_e, _direction, ref, _d) => {
|
||||||
|
setSidebarWidth(ref.offsetWidth)
|
||||||
|
}}
|
||||||
|
enable={{
|
||||||
|
right: true,
|
||||||
|
}}
|
||||||
|
handleComponent={{
|
||||||
|
right: <div className="w-1 h-full cursor-col-resize hover:bg-blue-400 transition-colors" />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'border-r bg-white flex-shrink-0 flex flex-col'
|
||||||
|
)}
|
||||||
|
style={{ width: sidebarWidth }}
|
||||||
|
>
|
||||||
|
{/* Logo 区域 */}
|
||||||
|
<div className={cn(
|
||||||
|
'h-12 flex items-center border-b px-3'
|
||||||
|
)}>
|
||||||
|
{logo && (
|
||||||
|
<div className="flex-shrink-0 flex items-center gap-2">{logo}</div>
|
||||||
|
)}
|
||||||
|
{title && (
|
||||||
|
<span className="text-sm font-medium text-gray-900 truncate ml-1">{title}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(true)}
|
||||||
|
className="ml-auto flex-shrink-0 p-1 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
title="收起"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 导航区域 */}
|
||||||
|
<nav className="flex-1 p-2 overflow-y-auto">
|
||||||
|
<ul className="space-y-1 list-none">
|
||||||
|
{items.map((item) => renderNavItem(item))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</Resizable>
|
||||||
|
) : (
|
||||||
|
// 收起状态
|
||||||
|
<aside className="w-14 border-r bg-white flex-shrink-0 flex flex-col">
|
||||||
|
{/* Logo 区域 */}
|
||||||
|
<div className="h-12 flex items-center justify-center border-b px-2">
|
||||||
|
<div className="group relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(false)}
|
||||||
|
className="p-1 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
title="展开"
|
||||||
|
>
|
||||||
|
<span className="group-hover:hidden">{logo}</span>
|
||||||
|
<ChevronRight className="w-5 h-5 hidden group-hover:block text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 导航区域 */}
|
||||||
|
<nav className="flex-1 p-2 overflow-y-auto">
|
||||||
|
<ul className="space-y-1 list-none">
|
||||||
|
{items.map((item) => renderNavItem(item))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主内容区域 */}
|
||||||
|
<main className="flex-1 overflow-auto h-full bg-gray-50">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 开发中弹窗 */}
|
||||||
|
<Dialog
|
||||||
|
open={developingDialog.open}
|
||||||
|
onOpenChange={(open) => setDevelopingDialog({ open, title: '' })}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{developingDialog.title} - 开发中</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
该功能正在紧张开发中,敬请期待!暂时无法访问。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setDevelopingDialog({ open: false, title: '' })}>
|
||||||
|
知道了
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
src/components/ui/breadcrumb.tsx
Normal file
125
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("gap-1 inline-flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"a">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "a",
|
||||||
|
props: mergeProps<"a">(
|
||||||
|
{
|
||||||
|
className: cn("hover:text-foreground transition-colors", className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "breadcrumb-link",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<ChevronRightIcon />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"size-5 [&>svg]:size-4 flex items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|||||||
188
src/components/ui/command.tsx
Normal file
188
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground rounded-xl! p-1 flex size-full flex-col overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl! top-1/3 translate-y-0 overflow-hidden p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||||
|
<InputGroup className="bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn("text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! [&_svg:not([class*='size-'])]:size-4 group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn("text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
@@ -30,7 +28,7 @@ function DialogOverlay({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Backdrop
|
<DialogPrimitive.Backdrop
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/20 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function DropdownMenuSubContent({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-md p-1 shadow-lg ring-1 duration-100 w-auto", className)}
|
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 w-auto", className)}
|
||||||
align={align}
|
align={align}
|
||||||
alignOffset={alignOffset}
|
alignOffset={alignOffset}
|
||||||
side={side}
|
side={side}
|
||||||
|
|||||||
149
src/components/ui/input-group.tsx
Normal file
149
src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start": "pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first",
|
||||||
|
"inline-end": "pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last",
|
||||||
|
"block-start":
|
||||||
|
"px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
|
||||||
|
"block-end":
|
||||||
|
"px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"gap-2 text-sm shadow-none flex items-center",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: "",
|
||||||
|
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants> & {
|
||||||
|
type?: "button" | "submit" | "reset"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn("rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn("rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
26
src/components/ui/kbd.tsx
Normal file
26
src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 h-5 w-fit min-w-5 gap-1 rounded-sm px-1 font-sans text-xs font-medium [&_svg:not([class*='size-'])]:size-3 pointer-events-none inline-flex items-center justify-center select-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn("gap-1 inline-flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup }
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|||||||
265
src/components/ui/menubar.tsx
Normal file
265
src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive
|
||||||
|
data-slot="menubar"
|
||||||
|
className={cn("bg-background h-8 gap-0.5 rounded-lg border p-[3px] flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
|
||||||
|
return <DropdownMenu data-slot="menubar-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuGroup>) {
|
||||||
|
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPortal>) {
|
||||||
|
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
data-slot="menubar-trigger"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted aria-expanded:bg-muted rounded-sm px-1.5 py-[2px] text-sm font-medium flex items-center outline-hidden select-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -4,
|
||||||
|
sideOffset = 8,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="menubar-content"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn("bg-popover text-popover-foreground data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-slot="menubar-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn("focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-disabled:opacity-50 data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/menubar-item", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="menubar-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm data-inset:pl-7 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="left-1.5 size-4 [&_svg:not([class*='size-'])]:size-4 pointer-events-none absolute flex items-center justify-center">
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
|
||||||
|
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="menubar-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm data-disabled:opacity-50 data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="left-1.5 size-4 [&_svg:not([class*='size-'])]:size-4 pointer-events-none absolute flex items-center justify-center">
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuLabel> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuLabel
|
||||||
|
data-slot="menubar-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn("px-1.5 py-1 text-sm font-medium data-inset:pl-7", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSeparator
|
||||||
|
data-slot="menubar-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuShortcut
|
||||||
|
data-slot="menubar-shortcut"
|
||||||
|
className={cn("text-muted-foreground group-focus/menubar-item:text-accent-foreground text-xs tracking-widest ml-auto", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSub>) {
|
||||||
|
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
data-slot="menubar-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn("focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSubContent
|
||||||
|
data-slot="menubar-sub-content"
|
||||||
|
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-32 rounded-lg p-1 shadow-lg ring-1 duration-100", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarShortcut,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarSubContent,
|
||||||
|
}
|
||||||
129
src/components/ui/sheet.tsx
Normal file
129
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Backdrop
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Popup.Props & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Popup
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn("bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Popup>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("gap-0.5 p-4 flex flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("gap-2 p-4 mt-auto flex flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground text-base font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ function TooltipContent({
|
|||||||
<TooltipPrimitive.Popup
|
<TooltipPrimitive.Popup
|
||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-neutral-900 text-white shadow-lg z-50 w-fit max-w-xs origin-(--transform-origin)",
|
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-foreground text-background z-50 w-fit max-w-xs origin-(--transform-origin)",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 bg-neutral-900 fill-neutral-900 z-50 data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 bg-foreground fill-foreground z-50 data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
</TooltipPrimitive.Popup>
|
</TooltipPrimitive.Popup>
|
||||||
</TooltipPrimitive.Positioner>
|
</TooltipPrimitive.Positioner>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { basename } from './modules/basename'
|
import { getDynamicBasename } from './modules/basename'
|
||||||
// import './agents/app'
|
import './agents/index.ts';
|
||||||
// Set up a Router instance
|
// Set up a Router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
basepath: basename,
|
basepath: getDynamicBasename(),
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,2 +1,32 @@
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export const basename = BASE_NAME;
|
export const basename = BASE_NAME;
|
||||||
|
|
||||||
|
export const wrapBasename = (path: string) => {
|
||||||
|
const hasEnd = path.endsWith('/')
|
||||||
|
if (basename) {
|
||||||
|
return `${basename}${path}` + (hasEnd ? '' : '/');
|
||||||
|
} else {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态计算 basename,根据当前 URL 路径
|
||||||
|
export const getDynamicBasename = (): string => {
|
||||||
|
const path = window.location.pathname
|
||||||
|
const [user, key, id] = path.split('/').filter(Boolean)
|
||||||
|
if (key === 'v1' && id) {
|
||||||
|
return `/${user}/v1/${id}`
|
||||||
|
}
|
||||||
|
// 默认使用构建时的 basename
|
||||||
|
return basename
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openLink = (path: string, target: string = '_self') => {
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
window.open(path, target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
url.pathname = wrapBasename(url.pathname);
|
||||||
|
window.open(url.toString(), target);
|
||||||
|
}
|
||||||
1227
src/modules/cnb-api.ts
Normal file
1227
src/modules/cnb-api.ts
Normal file
File diff suppressed because it is too large
Load Diff
378
src/modules/mark-api.ts
Normal file
378
src/modules/mark-api.ts
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import { createQueryApi } from '@kevisual/query/api';
|
||||||
|
const api = {
|
||||||
|
"mark": {
|
||||||
|
/**
|
||||||
|
* 获取mark列表
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.page - {number} 页码
|
||||||
|
* @param data.pageSize - {number} 每页数量
|
||||||
|
* @param data.search - {string} 搜索关键词
|
||||||
|
* @param data.markType - {string} mark类型,simple,wallnote,md,draw等
|
||||||
|
* @param data.sort - {"DESC" | "ASC"} 排序字段
|
||||||
|
*/
|
||||||
|
"list": {
|
||||||
|
"path": "mark",
|
||||||
|
"key": "list",
|
||||||
|
"description": "获取mark列表",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"page": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "页码",
|
||||||
|
"type": "number",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pageSize": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "每页数量",
|
||||||
|
"type": "number",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "搜索关键词",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"markType": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "mark类型,simple,wallnote,md,draw等",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": "DESC",
|
||||||
|
"description": "排序字段",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"DESC",
|
||||||
|
"ASC"
|
||||||
|
],
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/api/router",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 获取mark版本信息
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.id - {string} mark id
|
||||||
|
*/
|
||||||
|
"getVersion": {
|
||||||
|
"path": "mark",
|
||||||
|
"key": "getVersion",
|
||||||
|
"description": "获取mark版本信息",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"id": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "mark id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/api/router",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 获取mark详情
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.id - {string} mark id
|
||||||
|
*/
|
||||||
|
"get": {
|
||||||
|
"path": "mark",
|
||||||
|
"key": "get",
|
||||||
|
"description": "获取mark详情",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"id": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "mark id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/api/router",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 更新mark内容
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.id - {string} mark id
|
||||||
|
* @param data.data - {object}
|
||||||
|
*/
|
||||||
|
"update": {
|
||||||
|
"path": "mark",
|
||||||
|
"key": "update",
|
||||||
|
"description": "更新mark内容",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"id": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "mark id"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"default": "",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"default": "",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"default": "",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"default": "",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"tags",
|
||||||
|
"link",
|
||||||
|
"summary",
|
||||||
|
"description"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/api/router",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 更新mark节点,支持更新和删除操作
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.id - {string} mark id
|
||||||
|
* @param data.operate - {"update" | "delete"} 节点操作类型,update或delete
|
||||||
|
* @param data.data - {object} 要更新的节点数据
|
||||||
|
*/
|
||||||
|
"updateNode": {
|
||||||
|
"path": "mark",
|
||||||
|
"key": "updateNode",
|
||||||
|
"description": "更新mark节点,支持更新和删除操作",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"id": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "mark id"
|
||||||
|
},
|
||||||
|
"operate": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": "update",
|
||||||
|
"description": "节点操作类型,update或delete",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"update",
|
||||||
|
"delete"
|
||||||
|
],
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "节点id"
|
||||||
|
},
|
||||||
|
"node": {
|
||||||
|
"description": "要更新的节点数据"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "要更新的节点数据"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/api/router",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 批量更新mark节点,支持更新和删除操作
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.id - {string} mark id
|
||||||
|
* @param data.nodeOperateList - {array} 要更新的节点列表
|
||||||
|
*/
|
||||||
|
"updateNodes": {
|
||||||
|
"path": "mark",
|
||||||
|
"key": "updateNodes",
|
||||||
|
"description": "批量更新mark节点,支持更新和删除操作",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"id": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "mark id"
|
||||||
|
},
|
||||||
|
"nodeOperateList": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"operate": {
|
||||||
|
"default": "update",
|
||||||
|
"description": "节点操作类型,update或delete",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"update",
|
||||||
|
"delete"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node": {
|
||||||
|
"description": "要更新的节点数据"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"operate",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"description": "要更新的节点列表"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/api/router",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.id - {string} mark id
|
||||||
|
*/
|
||||||
|
"delete": {
|
||||||
|
"path": "mark",
|
||||||
|
"key": "delete",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"id": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "mark id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/api/router",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 创建一个新的mark.
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.title - {string} 标题
|
||||||
|
* @param data.tags - {unknown} 标签
|
||||||
|
* @param data.link - {string} 链接
|
||||||
|
* @param data.summary - {string} 摘要
|
||||||
|
* @param data.description - {string} 描述
|
||||||
|
* @param data.markType - {string} mark类型
|
||||||
|
* @param data.config - {unknown} 配置
|
||||||
|
* @param data.data - {unknown} 数据
|
||||||
|
*/
|
||||||
|
"create": {
|
||||||
|
"path": "mark",
|
||||||
|
"key": "create",
|
||||||
|
"description": "创建一个新的mark.",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"title": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": "",
|
||||||
|
"description": "标题",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"default": [],
|
||||||
|
"description": "标签",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": "",
|
||||||
|
"description": "链接",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": "",
|
||||||
|
"description": "摘要",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": "",
|
||||||
|
"description": "描述",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"markType": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": "md",
|
||||||
|
"description": "mark类型",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": {},
|
||||||
|
"description": "配置",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": {},
|
||||||
|
"description": "数据",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/api/router",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 获取mark菜单
|
||||||
|
*/
|
||||||
|
"getMenu": {
|
||||||
|
"path": "mark",
|
||||||
|
"key": "getMenu",
|
||||||
|
"description": "获取mark菜单",
|
||||||
|
"metadata": {
|
||||||
|
"url": "/api/router",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
const queryApi = createQueryApi({ api });
|
||||||
|
|
||||||
|
export { queryApi };
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Query, DataOpts } from '@kevisual/query';
|
import { Query, DataOpts } from '@kevisual/query';
|
||||||
import { QueryLoginBrowser } from '@kevisual/api/query-login'
|
import { QueryLoginBrowser } from '@kevisual/api/query-login'
|
||||||
import { useContextKey } from '@kevisual/context';
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
export const query = useContextKey('query', new Query({
|
export const query = useContextKey('query', new Query({
|
||||||
url: '/api/router',
|
url: '/api/router',
|
||||||
}));
|
}));
|
||||||
@@ -12,3 +14,5 @@ export const queryClient = useContextKey('queryClient', new Query({
|
|||||||
export const queryLogin = useContextKey('queryLogin', new QueryLoginBrowser({
|
export const queryLogin = useContextKey('queryLogin', new QueryLoginBrowser({
|
||||||
query: query
|
query: query
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const stackQueryClient = useContextKey('stackQueryClient', new QueryClient());
|
||||||
1
src/pages/auth/hooks/index.ts
Normal file
1
src/pages/auth/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './use-api-query';
|
||||||
55
src/pages/auth/hooks/use-api-query.ts
Normal file
55
src/pages/auth/hooks/use-api-query.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { queryLogin } from '@/modules/query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { UserInfo } from '../store';
|
||||||
|
|
||||||
|
export const authQueryKeys = {
|
||||||
|
me: ['auth', 'me'] as const,
|
||||||
|
token: ['auth', 'token'] as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const useMe = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: authQueryKeys.me,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await queryLogin.getMe();
|
||||||
|
if (res.code === 200) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
throw new Error(res.message || 'Failed to fetch user info');
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSwitchOrg = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (username?: string) => {
|
||||||
|
const res = await queryLogin.switchUser(username || '');
|
||||||
|
if (res.code === 200) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
throw new Error(res.message || 'Switch failed');
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('切换成功');
|
||||||
|
queryClient.invalidateQueries({ queryKey: authQueryKeys.me });
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || '请求失败');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetToken = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: authQueryKeys.token,
|
||||||
|
queryFn: () => queryLogin.getToken(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -6,7 +6,6 @@ export { BaseHeader } from './modules/BaseHeader'
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useLocation, useNavigate } from '@tanstack/react-router';
|
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: React.ReactNode,
|
children?: React.ReactNode,
|
||||||
mustLogin?: boolean,
|
mustLogin?: boolean,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const BaseHeader = (props: { main?: React.ComponentType | null }) => {
|
|||||||
me: state.me,
|
me: state.me,
|
||||||
clearMe: state.clearMe,
|
clearMe: state.clearMe,
|
||||||
links: state.links,
|
links: state.links,
|
||||||
|
showBaseHeader: state.showBaseHeader,
|
||||||
})));
|
})));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const meInfo = useMemo(() => {
|
const meInfo = useMemo(() => {
|
||||||
@@ -48,6 +49,9 @@ export const BaseHeader = (props: { main?: React.ComponentType | null }) => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}, [store.me, store.clearMe])
|
}, [store.me, store.clearMe])
|
||||||
|
if (!store.showBaseHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-2 text-lg w-full h-12 items-center justify-between bg-gray-200">
|
<div className="flex gap-2 text-lg w-full h-12 items-center justify-between bg-gray-200">
|
||||||
@@ -79,7 +83,6 @@ export const BaseHeader = (props: { main?: React.ComponentType | null }) => {
|
|||||||
{meInfo}
|
{meInfo}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
|
||||||
import { queryLogin } from '@/modules/query';
|
import { queryLogin, stackQueryClient } from '@/modules/query';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
type UserInfo = {
|
import { authQueryKeys } from './hooks';
|
||||||
|
export type UserInfo = {
|
||||||
id?: string;
|
id?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
@@ -35,6 +36,11 @@ export type LayoutStore = {
|
|||||||
setLoginPageConfig: (config: Partial<LayoutStore['loginPageConfig']>) => void;
|
setLoginPageConfig: (config: Partial<LayoutStore['loginPageConfig']>) => void;
|
||||||
links: HeaderLink[];
|
links: HeaderLink[];
|
||||||
setLinks: (links: HeaderLink[]) => void;
|
setLinks: (links: HeaderLink[]) => void;
|
||||||
|
showBaseHeader: boolean;
|
||||||
|
setShowBaseHeader: (showBaseHeader: boolean) => void;
|
||||||
|
serverData: Record<string, any> | null;
|
||||||
|
setServerData: (data: Record<string, any>) => void;
|
||||||
|
initConvex: () => Promise<void>;
|
||||||
};
|
};
|
||||||
type HeaderLink = {
|
type HeaderLink = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -54,19 +60,25 @@ export const useLayoutStore = create<LayoutStore>((set, get) => ({
|
|||||||
setMe: (me) => set({ me }),
|
setMe: (me) => set({ me }),
|
||||||
clearMe: () => {
|
clearMe: () => {
|
||||||
set({ me: undefined, isAdmin: false });
|
set({ me: undefined, isAdmin: false });
|
||||||
window.location.href = '/root/login/?redirect=' + encodeURIComponent(window.location.href);
|
|
||||||
},
|
},
|
||||||
getMe: async () => {
|
getMe: async () => {
|
||||||
const res = await queryLogin.getMe();
|
const data = await stackQueryClient.fetchQuery({
|
||||||
if (res.code === 200) {
|
queryKey: authQueryKeys.me,
|
||||||
set({ me: res.data });
|
queryFn: async () => {
|
||||||
set({ isAdmin: res.data.orgs?.includes?.('admin') || false });
|
const res = await queryLogin.getMe();
|
||||||
}
|
if (res.code === 200) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
throw new Error(res.message || 'Failed to fetch user info');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
set({ me: data, isAdmin: data?.orgs?.includes?.('admin') || false });
|
||||||
},
|
},
|
||||||
switchOrg: async (username?: string) => {
|
switchOrg: async (username?: string) => {
|
||||||
const res = await queryLogin.switchUser(username || '');
|
const res = await queryLogin.switchUser(username || '');
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
toast.success('切换成功');
|
toast.success('切换成功');
|
||||||
|
stackQueryClient.invalidateQueries({ queryKey: authQueryKeys.me });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -77,20 +89,32 @@ export const useLayoutStore = create<LayoutStore>((set, get) => ({
|
|||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
setIsAdmin: (isAdmin) => set({ isAdmin }),
|
setIsAdmin: (isAdmin) => set({ isAdmin }),
|
||||||
init: async () => {
|
init: async () => {
|
||||||
const token = await queryLogin.getToken();
|
await queryLogin.init();
|
||||||
|
const token = await queryLogin.checkLocalToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
set({ me: {} })
|
set({ me: {} });
|
||||||
const me = await queryLogin.getMe();
|
try {
|
||||||
// const user = await queryLogin.checkLocalUser() as UserInfo;
|
// const data = await stackQueryClient.fetchQuery({
|
||||||
const user = me.code === 200 ? me.data : undefined;
|
// queryKey: authQueryKeys.me,
|
||||||
if (user) {
|
// }) as UserInfo;
|
||||||
set({ me: user });
|
const userInfo = await queryLogin.checkLocalUser();
|
||||||
set({ isAdmin: user.orgs?.includes?.('admin') || false });
|
if (userInfo) {
|
||||||
} else {
|
set({ me: userInfo as UserInfo, isAdmin: userInfo.orgs?.includes?.('admin') || false });
|
||||||
|
} else {
|
||||||
|
set({ me: undefined, isAdmin: false });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
set({ me: undefined, isAdmin: false });
|
set({ me: undefined, isAdmin: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 获取服务端数据
|
||||||
|
// @ts-ignore
|
||||||
|
const sererData = window.__SERVER_DATA__;
|
||||||
|
if (sererData) {
|
||||||
|
set({ serverData: sererData });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
initConvex: async () => { },
|
||||||
openLinkList: ['/login'],
|
openLinkList: ['/login'],
|
||||||
setOpenLinkList: (openLinkList) => set({ openLinkList }),
|
setOpenLinkList: (openLinkList) => set({ openLinkList }),
|
||||||
loginPageConfig: {
|
loginPageConfig: {
|
||||||
@@ -103,4 +127,8 @@ export const useLayoutStore = create<LayoutStore>((set, get) => ({
|
|||||||
})),
|
})),
|
||||||
links: [{ title: '', href: '/', key: 'home' }],
|
links: [{ title: '', href: '/', key: 'home' }],
|
||||||
setLinks: (links) => set({ links }),
|
setLinks: (links) => set({ links }),
|
||||||
|
showBaseHeader: true,
|
||||||
|
setShowBaseHeader: (showBaseHeader) => set({ showBaseHeader }),
|
||||||
|
serverData: null,
|
||||||
|
setServerData: (data) => set({ serverData: data }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
239
src/pages/cloud-env/page.tsx
Normal file
239
src/pages/cloud-env/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useCloudEnvStore } from './store'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { SidebarLayout } from '../sidebar/components'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
Code2,
|
||||||
|
Terminal,
|
||||||
|
MousePointer2,
|
||||||
|
Lock,
|
||||||
|
Radio,
|
||||||
|
Zap,
|
||||||
|
Square,
|
||||||
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Wind,
|
||||||
|
Plane,
|
||||||
|
Rocket,
|
||||||
|
ExternalLink
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { WorkspaceInfo } from '@kevisual/cnb'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
type WorkspaceOpen = {
|
||||||
|
url?: string
|
||||||
|
webide?: string
|
||||||
|
jumpUrl?: string
|
||||||
|
remoteSsh?: string
|
||||||
|
jetbrains?: Record<string, string>
|
||||||
|
codebuddy?: string
|
||||||
|
codebuddycn?: string
|
||||||
|
vscode?: string
|
||||||
|
cursor?: string
|
||||||
|
'vscode-insiders'?: string
|
||||||
|
trae?: string
|
||||||
|
'trae-cn'?: string
|
||||||
|
windsurf?: string
|
||||||
|
'windsurf-next'?: string
|
||||||
|
antigravity?: string
|
||||||
|
ssh?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
getUrl: (data: WorkspaceOpen) => string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkItems: LinkItem[] = [
|
||||||
|
{ key: 'jumpUrl', label: 'Jump', icon: <ExternalLink className="w-5 h-5" />, getUrl: (d) => d.jumpUrl },
|
||||||
|
{ key: 'webide', label: 'Web IDE', icon: <Code2 className="w-5 h-5" />, getUrl: (d) => d.webide },
|
||||||
|
{ key: 'vscode', label: 'VS Code', icon: <Code2 className="w-5 h-5" />, getUrl: (d) => d.vscode },
|
||||||
|
{ key: 'cursor', label: 'Cursor', icon: <MousePointer2 className="w-5 h-5" />, getUrl: (d) => d.cursor },
|
||||||
|
{ key: 'trae-cn', label: 'Trae', icon: <Rocket className="w-5 h-5" />, getUrl: (d) => d['trae-cn'] },
|
||||||
|
{ key: 'windsurf', label: 'Windsurf', icon: <Wind className="w-5 h-5" />, getUrl: (d) => d.windsurf },
|
||||||
|
{ key: 'antigravity', label: 'Antigravity', icon: <Plane className="w-5 h-5" />, getUrl: (d) => d.antigravity },
|
||||||
|
{ key: 'ssh', label: 'SSH', icon: <Lock className="w-5 h-5" />, getUrl: (d) => d.ssh },
|
||||||
|
{ key: 'remoteSsh', label: 'Remote SSH', icon: <Radio className="w-5 h-5" />, getUrl: (d) => d.remoteSsh },
|
||||||
|
{ key: 'codebuddycn', label: 'CodeBuddy', icon: <Zap className="w-5 h-5" />, getUrl: (d) => d.codebuddycn },
|
||||||
|
]
|
||||||
|
|
||||||
|
function LinkCard({ item, workspaceData }: { item: LinkItem; workspaceData: WorkspaceOpen }) {
|
||||||
|
const url = item.getUrl(workspaceData)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
if (!url) return null
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (url.startsWith('ssh') || url.startsWith('cnb')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
setCopied(true)
|
||||||
|
toast.success('已复制')
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
toast.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 p-2.5 rounded-lg border border-neutral-200 transition-all cursor-pointer',
|
||||||
|
'hover:border-neutral-900 hover:bg-neutral-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-neutral-700 shrink-0">{item.icon}</div>
|
||||||
|
<span className="text-sm font-medium text-neutral-900 truncate flex-1">{item.label}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-1 rounded hover:bg-neutral-100 shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4 text-neutral-400" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="max-w-xs break-all">{url}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceCard({ workspace, onStop }: { workspace: WorkspaceInfo; onStop: (ws: WorkspaceInfo) => void }) {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [workspaceData, setWorkspaceData] = useState<WorkspaceOpen | null>(null)
|
||||||
|
const getWorkspaceDetail = useCloudEnvStore((state) => state.getWorkspaceDetail)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDetail = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await getWorkspaceDetail(workspace)
|
||||||
|
setWorkspaceData(data)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
fetchDetail()
|
||||||
|
}, [workspace, getWorkspaceDetail])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-4 space-y-4 border border-neutral-200 bg-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-neutral-900">{workspace.slug}</span>
|
||||||
|
</div>
|
||||||
|
{workspace.branch && (
|
||||||
|
<Badge variant="outline" className="text-xs">{workspace.branch}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onStop(workspace)}
|
||||||
|
className="text-red-600 border-red-200 hover:bg-red-600 hover:text-white"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4 mr-1" />
|
||||||
|
停止
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="h-12 bg-neutral-100 rounded-lg animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : workspaceData ? (
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{linkItems.map((item) => (
|
||||||
|
<LinkCard key={item.key} item={item} workspaceData={workspaceData} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-neutral-400 py-4">暂无链接信息</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CloudEnvPage() {
|
||||||
|
const { workspaceList, loading, getWorkspaceList, stopWorkspace } = useCloudEnvStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getWorkspaceList()
|
||||||
|
}, [getWorkspaceList])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarLayout>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900">云端开发环境</h1>
|
||||||
|
<p className="text-neutral-500 mt-1">当前运行中的云端开发环境</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => getWorkspaceList()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx('w-4 h-4 mr-2', loading && 'animate-spin')} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && workspaceList.length === 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-40" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : workspaceList.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center border border-neutral-200">
|
||||||
|
<div className="text-neutral-400 mb-4">
|
||||||
|
<Terminal className="w-12 h-12 mx-auto mb-4" />
|
||||||
|
<p className="text-lg font-medium">暂无运行中的工作区</p>
|
||||||
|
<p className="text-sm mt-1">在仓库管理页面启动工作区即可在此查看</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{workspaceList.map((workspace) => (
|
||||||
|
<WorkspaceCard
|
||||||
|
key={workspace.sn}
|
||||||
|
workspace={workspace}
|
||||||
|
onStop={stopWorkspace}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
src/pages/cloud-env/store/index.ts
Normal file
91
src/pages/cloud-env/store/index.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { queryApi as cnbApi } from '@/modules/cnb-api'
|
||||||
|
import { WorkspaceInfo } from '@kevisual/cnb'
|
||||||
|
|
||||||
|
type WorkspaceOpen = {
|
||||||
|
url?: string
|
||||||
|
webide?: string
|
||||||
|
jumpUrl?: string
|
||||||
|
remoteSsh?: string
|
||||||
|
jetbrains?: Record<string, string>
|
||||||
|
codebuddy?: string
|
||||||
|
codebuddycn?: string
|
||||||
|
vscode?: string
|
||||||
|
cursor?: string
|
||||||
|
'vscode-insiders'?: string
|
||||||
|
trae?: string
|
||||||
|
'trae-cn'?: string
|
||||||
|
windsurf?: string
|
||||||
|
'windsurf-next'?: string
|
||||||
|
antigravity?: string
|
||||||
|
ssh?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
workspaceList: WorkspaceInfo[]
|
||||||
|
loading: boolean
|
||||||
|
getWorkspaceList: () => Promise<void>
|
||||||
|
getWorkspaceDetail: (data: WorkspaceInfo) => Promise<WorkspaceOpen | null>
|
||||||
|
stopWorkspace: (workspace: WorkspaceInfo) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCloudEnvStore = create<State>((set, get) => ({
|
||||||
|
workspaceList: [],
|
||||||
|
loading: false,
|
||||||
|
getWorkspaceList: async () => {
|
||||||
|
set({ loading: true })
|
||||||
|
try {
|
||||||
|
const res = await cnbApi.cnb['list-workspace']({
|
||||||
|
status: 'running',
|
||||||
|
pageSize: 100
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
const list: WorkspaceInfo[] = res.data?.list || []
|
||||||
|
set({ workspaceList: list })
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取工作区列表失败:', error)
|
||||||
|
toast.error('获取工作区列表失败')
|
||||||
|
} finally {
|
||||||
|
set({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getWorkspaceDetail: async (workspaceInfo: WorkspaceInfo): Promise<WorkspaceOpen | null> => {
|
||||||
|
try {
|
||||||
|
const res = await cnbApi.cnb['get-workspace']({
|
||||||
|
repo: workspaceInfo.slug,
|
||||||
|
sn: workspaceInfo.sn
|
||||||
|
}) as any
|
||||||
|
if (res.code === 200) {
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取工作区详情失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stopWorkspace: async (workspace: WorkspaceInfo) => {
|
||||||
|
const sn = workspace.sn
|
||||||
|
if (!sn) {
|
||||||
|
toast.error('工作区 SN 不存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await cnbApi.cnb['stop-workspace']({ sn })
|
||||||
|
if (res?.code === 200) {
|
||||||
|
toast.success('工作区已停止')
|
||||||
|
// 刷新列表
|
||||||
|
await get().getWorkspaceList()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '停止失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('停止工作区失败:', error)
|
||||||
|
toast.error('停止失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
906
src/pages/cnb/api.ts
Normal file
906
src/pages/cnb/api.ts
Normal file
@@ -0,0 +1,906 @@
|
|||||||
|
import { createQueryApi } from '@kevisual/query/api';
|
||||||
|
import { queryClient as query } from '@/modules/query.ts';
|
||||||
|
const api = {
|
||||||
|
"cnb": {
|
||||||
|
/**
|
||||||
|
* 验证 CNB 登录信息是否有效
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.checkToken - {boolean} 是否检查 Token 的有效性
|
||||||
|
* @param data.checkCookie - {boolean} 是否检查 Cookie 的有效性
|
||||||
|
*/
|
||||||
|
"user-check": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "user-check",
|
||||||
|
"description": "检查用户登录状态,参数checkToken,default true; checkCookie, default false",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"checkToken": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": true,
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否检查 Token 的有效性",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"checkCookie": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否检查 Cookie 的有效性",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "cnb-login-verify",
|
||||||
|
"title": "CNB 登录验证信息",
|
||||||
|
"summary": "验证 CNB 登录信息是否有效",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 列出cnb代码仓库, 可选flags参数,如 KnowledgeBase
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.search - {string} 搜索关键词
|
||||||
|
* @param data.pageSize - {number} 每页数量,默认999
|
||||||
|
* @param data.flags - {string} 仓库标记,如果是知识库则填写 KnowledgeBase
|
||||||
|
*/
|
||||||
|
"list-repos": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "list-repos",
|
||||||
|
"description": "列出我的代码仓库",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"search": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "搜索关键词",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pageSize": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "每页数量,默认999",
|
||||||
|
"type": "number",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"flags": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "仓库标记,如果是知识库则填写 KnowledgeBase",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "list-repos",
|
||||||
|
"title": "列出cnb代码仓库",
|
||||||
|
"summary": "列出cnb代码仓库, 可选flags参数,如 KnowledgeBase",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 创建一个新的代码仓库
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.name - {string} 代码仓库名称, 如 my-user/my-repo
|
||||||
|
* @param data.visibility - {string} 代码仓库可见性, public 或 private
|
||||||
|
* @param data.description - {string} 代码仓库描述
|
||||||
|
*/
|
||||||
|
"create-repo": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "create-repo",
|
||||||
|
"description": "创建代码仓库, 参数name, visibility, description",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"name": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库名称, 如 my-user/my-repo"
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"default": "public",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库可见性, public 或 private",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库描述"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "create-repo",
|
||||||
|
"title": "创建代码仓库",
|
||||||
|
"summary": "创建一个新的代码仓库",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 在代码仓库中创建文件, encoding 可选,默认 raw
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.repoName - {string} 代码仓库名称, 如 my-user/my-repo
|
||||||
|
* @param data.filePath - {string} 文件路径, 如 src/index.ts
|
||||||
|
* @param data.content - {string} 文本的字符串的内容
|
||||||
|
* @param data.encoding - {string} 编码方式,如 raw
|
||||||
|
*/
|
||||||
|
"create-repo-file": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "create-repo-file",
|
||||||
|
"description": "在代码仓库中创建文件, repoName, filePath, content, encoding。使用CNB_COOKIE进行鉴权",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"repoName": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库名称, 如 my-user/my-repo"
|
||||||
|
},
|
||||||
|
"filePath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件路径, 如 src/index.ts"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "文本的字符串的内容"
|
||||||
|
},
|
||||||
|
"encoding": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "编码方式,如 raw",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "create-repo-file",
|
||||||
|
"title": "在代码仓库中创建文件",
|
||||||
|
"summary": "在代码仓库中创建文件, encoding 可选,默认 raw",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 删除一个代码仓库
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.name - {string} 代码仓库名称
|
||||||
|
*/
|
||||||
|
"delete-repo": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "delete-repo",
|
||||||
|
"description": "删除代码仓库, 参数name",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"name": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库名称"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "delete-repo",
|
||||||
|
"title": "删除代码仓库",
|
||||||
|
"summary": "删除一个代码仓库",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 批量删除已停止的cnb工作空间,释放资源
|
||||||
|
*/
|
||||||
|
"clean-closed-workspace": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "clean-closed-workspace",
|
||||||
|
"description": "批量删除已停止的cnb工作空间",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {},
|
||||||
|
"skill": "clean-closed-workspace",
|
||||||
|
"title": "清理已关闭的cnb工作空间",
|
||||||
|
"summary": "批量删除已停止的cnb工作空间,释放资源",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 保持工作空间存活技能,参数repo:代码仓库路径,例如 user/repo,pipelineId:流水线ID,例如 cnb-708-1ji9sog7o-001
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.repo - {string} 代码仓库路径,例如 user/repo
|
||||||
|
* @param data.pipelineId - {string} 流水线ID,例如 cnb-708-1ji9sog7o-001
|
||||||
|
*/
|
||||||
|
"keep-workspace-alive": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "keep-workspace-alive",
|
||||||
|
"description": "保持工作空间存活技能,参数repo:代码仓库路径,例如 user/repo,pipelineId:流水线ID,例如 cnb-708-1ji9sog7o-001",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [],
|
||||||
|
"args": {
|
||||||
|
"repo": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库路径,例如 user/repo"
|
||||||
|
},
|
||||||
|
"pipelineId": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "流水线ID,例如 cnb-708-1ji9sog7o-001"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 停止保持工作空间存活技能, 参数repo:代码仓库路径,例如 user/repo,pipelineId:流水线ID,例如 cnb-708-1ji9sog7o-001
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.repo - {string} 代码仓库路径,例如 user/repo
|
||||||
|
* @param data.pipelineId - {string} 流水线ID,例如 cnb-708-1ji9sog7o-001
|
||||||
|
*/
|
||||||
|
"stop-keep-workspace-alive": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "stop-keep-workspace-alive",
|
||||||
|
"description": "停止保持工作空间存活技能, 参数repo:代码仓库路径,例如 user/repo,pipelineId:流水线ID,例如 cnb-708-1ji9sog7o-001",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [],
|
||||||
|
"args": {
|
||||||
|
"repo": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库路径,例如 user/repo"
|
||||||
|
},
|
||||||
|
"pipelineId": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "流水线ID,例如 cnb-708-1ji9sog7o-001"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 保持当前工作空间存活,防止被关闭或释放资源
|
||||||
|
*/
|
||||||
|
"keep-alive-current-workspace": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "keep-alive-current-workspace",
|
||||||
|
"description": "保持当前工作空间存活技能",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"skill": "keep-alive-current-workspace",
|
||||||
|
"title": "保持当前工作空间存活",
|
||||||
|
"summary": "保持当前工作空间存活,防止被关闭或释放资源",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 启动cnb工作空间
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.repo - {string} 代码仓库路径,例如 user/repo
|
||||||
|
* @param data.branch - {string} 分支名称,默认主分支
|
||||||
|
* @param data.ref - {string} 提交引用,例如 commit sha
|
||||||
|
*/
|
||||||
|
"start-workspace": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "start-workspace",
|
||||||
|
"description": "启动开发工作空间, 参数 repo",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"repo": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库路径,例如 user/repo"
|
||||||
|
},
|
||||||
|
"branch": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "分支名称,默认主分支",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"ref": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "提交引用,例如 commit sha",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "start-workspace",
|
||||||
|
"title": "启动cnb工作空间",
|
||||||
|
"summary": "启动cnb工作空间",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 列出cnb工作空间列表,支持按状态过滤, status 可选值 running 或 closed
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.status - {string} 开发环境状态,running: 运行中,closed: 已关闭和停止的
|
||||||
|
* @param data.page - {number} 分页页码,默认 1
|
||||||
|
* @param data.pageSize - {number} 分页大小,默认 20,最大 100
|
||||||
|
* @param data.slug - {string} 仓库路径,例如 groupname/reponame
|
||||||
|
* @param data.branch - {string} 分支名称
|
||||||
|
*/
|
||||||
|
"list-workspace": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "list-workspace",
|
||||||
|
"description": "获取cnb开发工作空间列表,可选参数 status=running 获取运行中的环境",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"status": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "开发环境状态,running: 运行中,closed: 已关闭和停止的",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "分页页码,默认 1",
|
||||||
|
"type": "number",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pageSize": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "分页大小,默认 20,最大 100",
|
||||||
|
"type": "number",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "仓库路径,例如 groupname/reponame",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"branch": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "分支名称",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "list-workspace",
|
||||||
|
"title": "列出cnb工作空间",
|
||||||
|
"summary": "列出cnb工作空间列表,支持按状态过滤, status 可选值 running 或 closed",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 获取工作空间详细信息
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.repo - {string} 代码仓库路径,例如 user/repo
|
||||||
|
* @param data.sn - {string} 工作空间流水线的 sn
|
||||||
|
*/
|
||||||
|
"get-workspace": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "get-workspace",
|
||||||
|
"description": "获取工作空间详情,通过 repo 和 sn 获取",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"repo": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库路径,例如 user/repo"
|
||||||
|
},
|
||||||
|
"sn": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "工作空间流水线的 sn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "get-workspace",
|
||||||
|
"title": "获取工作空间详情",
|
||||||
|
"summary": "获取工作空间详细信息",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 删除工作空间,pipelineId 和 sn 二选一
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.pipelineId - {string} 流水线 ID,优先使用
|
||||||
|
* @param data.sn - {string} 流水线构建号
|
||||||
|
* @param data.sns - {array} 批量流水线构建号
|
||||||
|
*/
|
||||||
|
"delete-workspace": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "delete-workspace",
|
||||||
|
"description": "删除工作空间,通过 pipelineId 或 sn",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"pipelineId": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "流水线 ID,优先使用",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sn": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "流水线构建号",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sns": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "批量流水线构建号",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "delete-workspace",
|
||||||
|
"title": "删除工作空间",
|
||||||
|
"summary": "删除工作空间,pipelineId 和 sn 二选一",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 停止运行中的工作空间
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.pipelineId - {string} 流水线 ID,优先使用
|
||||||
|
* @param data.sn - {string} 流水线构建号
|
||||||
|
*/
|
||||||
|
"stop-workspace": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "stop-workspace",
|
||||||
|
"description": "停止工作空间,通过 pipelineId 或 sn",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"pipelineId": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "流水线 ID,优先使用",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sn": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "流水线构建号",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "stop-workspace",
|
||||||
|
"title": "停止工作空间",
|
||||||
|
"summary": "停止运行中的工作空间",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 获取当前cnb工作空间的port代理uri,用于端口转发
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.port - {number} 端口号,默认为51515
|
||||||
|
*/
|
||||||
|
"get-cnb-port-uri": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "get-cnb-port-uri",
|
||||||
|
"description": "获取当前cnb工作空间的port代理uri",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"port": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "端口号,默认为51515",
|
||||||
|
"type": "number",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "get-cnb-port-uri",
|
||||||
|
"title": "获取当前cnb工作空间的port代理uri",
|
||||||
|
"summary": "获取当前cnb工作空间的port代理uri,用于端口转发",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 获取当前cnb工作空间的vscode代理uri,用于在浏览器中访问vscode,包含多种访问方式,如web、vscode、codebuddy、cursor、ssh
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.web - {boolean} 是否获取vscode web的访问uri,默认为false
|
||||||
|
* @param data.vscode - {boolean} 是否获取vscode的代理uri,默认为true
|
||||||
|
* @param data.codebuddy - {boolean} 是否获取codebuddy的代理uri,默认为false
|
||||||
|
* @param data.cursor - {boolean} 是否获取cursor的代理uri,默认为false
|
||||||
|
* @param data.ssh - {boolean} 是否获取vscode remote ssh的连接字符串,默认为false
|
||||||
|
*/
|
||||||
|
"get-cnb-vscode-uri": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "get-cnb-vscode-uri",
|
||||||
|
"description": "获取当前cnb工作空间的vscode代理uri, 包括多种访问方式, 如web、vscode、codebuddy、cursor、ssh",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"web": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "是否获取vscode web的访问uri,默认为false",
|
||||||
|
"type": "boolean",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vscode": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "是否获取vscode的代理uri,默认为true",
|
||||||
|
"type": "boolean",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"codebuddy": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "是否获取codebuddy的代理uri,默认为false",
|
||||||
|
"type": "boolean",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"cursor": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "是否获取cursor的代理uri,默认为false",
|
||||||
|
"type": "boolean",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"ssh": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "是否获取vscode remote ssh的连接字符串,默认为false",
|
||||||
|
"type": "boolean",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "get-cnb-vscode-uri",
|
||||||
|
"title": "获取当前cnb工作空间的编辑器访问地址",
|
||||||
|
"summary": "获取当前cnb工作空间的vscode代理uri,用于在浏览器中访问vscode,包含多种访问方式,如web、vscode、codebuddy、cursor、ssh",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 设置当前cnb工作空间的cookie环境变量,用于界面操作定制模块功能,例子:CNBSESSION=xxxx;csrfkey=2222xxxx;
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.cookie - {string} cnb的cookie值
|
||||||
|
*/
|
||||||
|
"set-cnb-cookie": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "set-cnb-cookie",
|
||||||
|
"description": "设置当前cnb工作空间的cookie环境变量",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"cookie": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "cnb的cookie值"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "set-cnb-cookie",
|
||||||
|
"title": "设置当前cnb工作空间的cookie环境变量",
|
||||||
|
"summary": "设置当前cnb工作空间的cookie环境变量,用于界面操作定制模块功能,例子:CNBSESSION=xxxx;csrfkey=2222xxxx;",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 获取当前cnb工作空间的cookie环境变量,用于界面操作定制模块功能
|
||||||
|
*/
|
||||||
|
"get-cnb-cookie": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "get-cnb-cookie",
|
||||||
|
"description": "获取当前cnb工作空间的cookie环境变量",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {},
|
||||||
|
"skill": "get-cnb-cookie",
|
||||||
|
"title": "获取当前cnb工作空间的cookie环境变量",
|
||||||
|
"summary": "获取当前cnb工作空间的cookie环境变量,用于界面操作定制模块功能",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 调用cnb的知识库ai对话功能进行聊天,基于cnb提供的ai能力
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.question - {string} 用户输入的消息内容
|
||||||
|
* @param data.repo - {string} 知识库仓库ID,默认为空表示使用默认知识库
|
||||||
|
*/
|
||||||
|
"cnb-ai-chat": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "cnb-ai-chat",
|
||||||
|
"description": "调用cnb的知识库ai对话功能进行聊天",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"question": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "用户输入的消息内容"
|
||||||
|
},
|
||||||
|
"repo": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "知识库仓库ID,默认为空表示使用默认知识库",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "cnb-ai-chat",
|
||||||
|
"title": "调用cnb的知识库ai对话功能进行聊天",
|
||||||
|
"summary": "调用cnb的知识库ai对话功能进行聊天,基于cnb提供的ai能力",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 调用cnb的知识库RAG查询功能进行问答,基于cnb提供的知识库能力
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.question - {string} 用户输入的消息内容
|
||||||
|
* @param data.repo - {string} 知识库仓库ID,默认为空表示使用默认知识库
|
||||||
|
*/
|
||||||
|
"cnb-rag-query": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "cnb-rag-query",
|
||||||
|
"description": "调用cnb的知识库RAG查询功能进行问答",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"question": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "用户输入的消息内容"
|
||||||
|
},
|
||||||
|
"repo": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "知识库仓库ID,默认为空表示使用默认知识库",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "cnb-rag-query",
|
||||||
|
"title": "调用cnb的知识库RAG查询功能进行问答",
|
||||||
|
"summary": "调用cnb的知识库RAG查询功能进行问答,基于cnb提供的知识库能力",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 查询 Issue 列表
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.repo - {string} 代码仓库名称, 如 my-user/my-repo
|
||||||
|
* @param data.state - {string} Issue 状态:open 或 closed
|
||||||
|
* @param data.keyword - {string} 问题搜索关键词
|
||||||
|
* @param data.labels - {string} 问题标签,多个用逗号分隔
|
||||||
|
* @param data.page - {number} 分页页码,默认: 1
|
||||||
|
* @param data.page_size - {number} 分页每页大小,默认: 30
|
||||||
|
* @param data.order_by - {string} 排序方式,如 created_at, -updated_at
|
||||||
|
*/
|
||||||
|
"list-issues": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "list-issues",
|
||||||
|
"description": "查询 Issue 列表, 参数 repo, state, keyword, labels, page, page_size 等",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"repo": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库名称, 如 my-user/my-repo"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "Issue 状态:open 或 closed",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"keyword": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "问题搜索关键词",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "问题标签,多个用逗号分隔",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "分页页码,默认: 1",
|
||||||
|
"type": "number",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"page_size": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "分页每页大小,默认: 30",
|
||||||
|
"type": "number",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"order_by": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "排序方式,如 created_at, -updated_at",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "list-issues",
|
||||||
|
"title": "查询 Issue 列表",
|
||||||
|
"summary": "查询 Issue 列表",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 创建一个新的 Issue
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.repo - {string} 代码仓库名称, 如 my-user/my-repo
|
||||||
|
* @param data.title - {string} Issue 标题
|
||||||
|
* @param data.body - {string} Issue 描述内容
|
||||||
|
* @param data.assignees - {array} 指派人列表
|
||||||
|
* @param data.labels - {array} 标签列表
|
||||||
|
* @param data.priority - {string} 优先级
|
||||||
|
*/
|
||||||
|
"create-issue": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "create-issue",
|
||||||
|
"description": "创建 Issue, 参数 repo, title, body, assignees, labels, priority",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"repo": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库名称, 如 my-user/my-repo"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "Issue 标题"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "Issue 描述内容",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"assignees": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "指派人列表",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "标签列表",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "优先级",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "create-issue",
|
||||||
|
"title": "创建 Issue",
|
||||||
|
"summary": "创建一个新的 Issue",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 完成一个 Issue(将 state 改为 closed)
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.repo - {string} 代码仓库名称, 如 my-user/my-repo
|
||||||
|
* @param data.issueNumber - {unknown} Issue 编号
|
||||||
|
* @param data.state - {string} Issue 状态,默认为 closed
|
||||||
|
*/
|
||||||
|
"complete-issue": {
|
||||||
|
"path": "cnb",
|
||||||
|
"key": "complete-issue",
|
||||||
|
"description": "完成 Issue, 参数 repo, issueNumber",
|
||||||
|
"metadata": {
|
||||||
|
"tags": [
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
|
"args": {
|
||||||
|
"repo": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "代码仓库名称, 如 my-user/my-repo"
|
||||||
|
},
|
||||||
|
"issueNumber": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Issue 编号"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "Issue 状态,默认为 closed",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skill": "complete-issue",
|
||||||
|
"title": "完成 CNB的任务Issue",
|
||||||
|
"summary": "完成一个 Issue(将 state 改为 closed)",
|
||||||
|
"url": "/root/v1/dev-cnb",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
const queryApi = createQueryApi({ api, query });
|
||||||
|
|
||||||
|
export { queryApi };
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
import { useGiteaConfigStore } from './store';
|
import { useGiteaConfigStore } from './store';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { giteaConfigSchema } from './store/schema';
|
import { giteaConfigSchema } from './store/schema';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useLayoutStore } from '../../auth/store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
|
||||||
export const GiteaConfigPage = () => {
|
export const GiteaConfigPage = () => {
|
||||||
const { config, setConfig, resetConfig } = useGiteaConfigStore();
|
const { config, setConfig, resetConfig, saveToRemote, loadFromRemote, checkConfig } = useGiteaConfigStore();
|
||||||
|
const layoutStore = useLayoutStore(useShallow(state => ({ me: state.me })))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (layoutStore.me) {
|
||||||
|
checkConfig({ isUser: !!layoutStore.me, reload: true })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -29,15 +38,15 @@ export const GiteaConfigPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-2xl py-8">
|
<div className="container mx-auto max-w-2xl py-4 md:py-8 px-4">
|
||||||
<Card>
|
<div className="bg-white md:rounded-lg md:border md:shadow-sm">
|
||||||
<CardHeader>
|
<div className="p-4 md:p-6 border-b">
|
||||||
<CardTitle>Gitea 配置</CardTitle>
|
<h1 className="text-xl md:text-2xl font-bold">Gitea 配置</h1>
|
||||||
<CardDescription>
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
配置您的 Gitea API 设置。这些设置会保存在浏览器的本地存储中。
|
配置您的 Gitea API 设置。这些设置会保存在浏览器的本地存储中。
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div className="p-4 md:p-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="gitea-url">Gitea 地址</Label>
|
<Label htmlFor="gitea-url">Gitea 地址</Label>
|
||||||
@@ -61,15 +70,24 @@ export const GiteaConfigPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
||||||
<Button type="submit">保存配置</Button>
|
<Button type="submit" className="w-full sm:w-auto">保存配置</Button>
|
||||||
<Button type="button" variant="outline" onClick={resetConfig}>
|
<Button type="button" variant="outline" onClick={resetConfig} className="w-full sm:w-auto">
|
||||||
重置为默认值
|
重置为默认值
|
||||||
</Button>
|
</Button>
|
||||||
|
{layoutStore.me && <>
|
||||||
|
<Button type="button" variant="outline" onClick={loadFromRemote} className="w-full sm:w-auto">
|
||||||
|
获取远端配置
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={saveToRemote} className="w-full sm:w-auto">
|
||||||
|
保存到远端
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { GiteaConfig } from './schema';
|
import type { GiteaConfig } from './schema';
|
||||||
|
import { queryLogin } from '@/modules/query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
type GiteaConfigState = {
|
type GiteaConfigState = {
|
||||||
config: GiteaConfig;
|
config: GiteaConfig;
|
||||||
setConfig: (config: Partial<GiteaConfig>) => void;
|
setConfig: (config: Partial<GiteaConfig>) => void;
|
||||||
resetConfig: () => void;
|
resetConfig: () => void;
|
||||||
|
saveToRemote: () => Promise<void>;
|
||||||
|
loadFromRemote: () => Promise<boolean>;
|
||||||
|
checkConfig: (opts?: { isUser?: boolean, reload?: boolean }) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_CONFIG: GiteaConfig = {
|
const DEFAULT_CONFIG: GiteaConfig = {
|
||||||
@@ -35,7 +40,7 @@ const saveConfig = (config: GiteaConfig) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useGiteaConfigStore = create<GiteaConfigState>()((set) => ({
|
export const useGiteaConfigStore = create<GiteaConfigState>()((set, get) => ({
|
||||||
config: loadInitialConfig(),
|
config: loadInitialConfig(),
|
||||||
setConfig: (newConfig) =>
|
setConfig: (newConfig) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -47,4 +52,52 @@ export const useGiteaConfigStore = create<GiteaConfigState>()((set) => ({
|
|||||||
saveConfig(DEFAULT_CONFIG);
|
saveConfig(DEFAULT_CONFIG);
|
||||||
return set({ config: DEFAULT_CONFIG });
|
return set({ config: DEFAULT_CONFIG });
|
||||||
},
|
},
|
||||||
|
saveToRemote: async () => {
|
||||||
|
const _config = get().config;
|
||||||
|
const res = await queryLogin.post({
|
||||||
|
path: 'config',
|
||||||
|
key: 'update',
|
||||||
|
data: {
|
||||||
|
key: 'gitea_config.json',
|
||||||
|
data: _config,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('保存到远端成功')
|
||||||
|
} else {
|
||||||
|
toast.error('保存到远端失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loadFromRemote: async () => {
|
||||||
|
const setConfig = (config: GiteaConfig) => set({ config });
|
||||||
|
const res = await queryLogin.post({
|
||||||
|
path: 'config',
|
||||||
|
key: 'get',
|
||||||
|
data: {
|
||||||
|
key: 'gitea_config.json',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (res.code === 404) {
|
||||||
|
toast.error('远端配置不存在')
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (res.code === 200) {
|
||||||
|
const config = res.data?.data as GiteaConfig;
|
||||||
|
setConfig(config);
|
||||||
|
toast.success('获取远端配置成功')
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
checkConfig: async (opts?: { isUser?: boolean, reload?: boolean }) => {
|
||||||
|
const { GITEA_TOKEN } = get().config;
|
||||||
|
if (!GITEA_TOKEN && opts?.isUser) {
|
||||||
|
const res = await get().loadFromRemote();
|
||||||
|
if (opts?.reload && res) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,30 +1,15 @@
|
|||||||
import { useConfigStore } from './store';
|
import { useConfigStore } from './store';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import { configSchema } from './store/schema';
|
import { SidebarLayout } from '../sidebar/components';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useLayoutStore } from '../auth/store';
|
|
||||||
import { useShallow } from 'zustand/shallow';
|
|
||||||
|
|
||||||
export const ConfigPage = () => {
|
export const ConfigPage = () => {
|
||||||
const { config, setConfig, resetConfig, saveToRemote, loadFromRemote } = useConfigStore();
|
const { config, setConfig, saveToRemote, loadFromRemote } = useConfigStore();
|
||||||
const layoutStore = useLayoutStore(useShallow(state => ({ me: state.me })))
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const result = configSchema.safeParse(config);
|
saveToRemote();
|
||||||
if (result.success) {
|
|
||||||
toast.success('配置已保存')
|
|
||||||
setTimeout(() => {
|
|
||||||
location.reload()
|
|
||||||
}, 400)
|
|
||||||
} else {
|
|
||||||
console.error('验证错误:', result.error.format());
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (field: keyof typeof config, value: string | boolean) => {
|
const handleChange = (field: keyof typeof config, value: string | boolean) => {
|
||||||
@@ -32,16 +17,16 @@ export const ConfigPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<SidebarLayout>
|
||||||
<div className="container mx-auto max-w-2xl py-8">
|
<div className="container mx-auto max-w-2xl py-4 md:py-8 px-4">
|
||||||
<Card>
|
<div className="bg-white md:rounded-lg md:border md:shadow-sm">
|
||||||
<CardHeader>
|
<div className="p-4 md:p-6 border-b">
|
||||||
<CardTitle>CNB 配置</CardTitle>
|
<h1 className="text-xl md:text-2xl font-bold">CNB 配置</h1>
|
||||||
<CardDescription>
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
配置您的 CNB API 设置。这些设置会保存在浏览器的本地存储中。
|
配置您的 CNB API 设置。
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div className="p-4 md:p-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -83,7 +68,7 @@ export const ConfigPage = () => {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
用于身份验证的 Cookie 信息。
|
用于身份验证的 Cookie 信息,有效期7天。
|
||||||
<a
|
<a
|
||||||
href="https://cnb.cool/kevisual/cnb-live-extension"
|
href="https://cnb.cool/kevisual/cnb-live-extension"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -105,93 +90,19 @@ export const ConfigPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
||||||
<Label htmlFor="cors-url">跨域地址</Label>
|
<Button type="button" variant="outline" onClick={loadFromRemote} className="w-full sm:w-auto">
|
||||||
<Input
|
获取远端配置
|
||||||
id="cors-url"
|
</Button>
|
||||||
type="url"
|
<Button type="button" variant="outline" onClick={saveToRemote} className="w-full sm:w-auto">
|
||||||
value={config.CNB_CORS_URL}
|
保存到远端
|
||||||
onChange={(e) => handleChange('CNB_CORS_URL', e.target.value)}
|
|
||||||
placeholder="https://cors.example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="enable-cors"
|
|
||||||
checked={config.ENABLE_CORS}
|
|
||||||
onCheckedChange={(checked) => handleChange('ENABLE_CORS', checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="enable-cors" className="cursor-pointer">
|
|
||||||
启用跨域
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="ai-base-url">AI 基础地址</Label>
|
|
||||||
<Input
|
|
||||||
id="ai-base-url"
|
|
||||||
type="url"
|
|
||||||
value={config.AI_BASE_URL}
|
|
||||||
onChange={(e) => handleChange('AI_BASE_URL', e.target.value)}
|
|
||||||
placeholder="请输入 AI 基础地址"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="ai-model">AI 模型</Label>
|
|
||||||
<Input
|
|
||||||
id="ai-model"
|
|
||||||
type="text"
|
|
||||||
value={config.AI_MODEL}
|
|
||||||
onChange={(e) => handleChange('AI_MODEL', e.target.value)}
|
|
||||||
placeholder="请输入 AI 模型名称"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label htmlFor="ai-api-key">AI 密钥</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
如果使用 CNB 的 AI,密钥和 API 密钥一样即可
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
id="ai-api-key"
|
|
||||||
type="password"
|
|
||||||
value={config.AI_API_KEY}
|
|
||||||
onChange={(e) => handleChange('AI_API_KEY', e.target.value)}
|
|
||||||
placeholder="请输入您的 AI API 密钥"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Button type="submit">保存配置</Button>
|
|
||||||
<Button type="button" variant="outline" onClick={resetConfig}>
|
|
||||||
重置为默认值
|
|
||||||
</Button>
|
</Button>
|
||||||
{layoutStore.me && <>
|
|
||||||
<Button type="button" variant="outline" onClick={loadFromRemote}>
|
|
||||||
获取远端配置
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="outline" onClick={saveToRemote}>
|
|
||||||
保存到远端
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</SidebarLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { Config, defaultConfig } from './schema';
|
import type { Config, } from './schema';
|
||||||
import { queryLogin } from '@/modules/query';
|
import { queryLogin } from '@/modules/query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ type ConfigState = {
|
|||||||
setConfig: (config: Partial<Config>) => void;
|
setConfig: (config: Partial<Config>) => void;
|
||||||
resetConfig: () => void;
|
resetConfig: () => void;
|
||||||
saveToRemote: () => Promise<void>;
|
saveToRemote: () => Promise<void>;
|
||||||
loadFromRemote: () => Promise<void>;
|
loadFromRemote: () => Promise<boolean>;
|
||||||
checkConfig: (opts?: { isUser?: boolean, reload?: boolean }) => Promise<boolean>;
|
checkConfig: (opts?: { isUser?: boolean, reload?: boolean }) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,11 +18,6 @@ const STORAGE_KEY = 'cnb-config';
|
|||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
CNB_API_KEY: '',
|
CNB_API_KEY: '',
|
||||||
CNB_COOKIE: '',
|
CNB_COOKIE: '',
|
||||||
CNB_CORS_URL: 'https://cors.kevisual.cn',
|
|
||||||
ENABLE_CORS: true,
|
|
||||||
AI_BASE_URL: 'https://api.cnb.cool/kevisual/cnb-ai/-/ai/',
|
|
||||||
AI_MODEL: 'CNB-Models',
|
|
||||||
AI_API_KEY: ''
|
|
||||||
}
|
}
|
||||||
const loadInitialConfig = (): Config => {
|
const loadInitialConfig = (): Config => {
|
||||||
try {
|
try {
|
||||||
@@ -75,22 +70,24 @@ export const useConfigStore = create<ConfigState>()(
|
|||||||
})
|
})
|
||||||
if (res.code === 404) {
|
if (res.code === 404) {
|
||||||
toast.error('远端配置不存在')
|
toast.error('远端配置不存在')
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
const config = res.data?.data as typeof config;
|
const config = res.data?.data as typeof config;
|
||||||
setConfig(config);
|
setConfig(config);
|
||||||
toast.success('获取远端配置成功')
|
toast.success('获取远端配置成功')
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
checkConfig: async (opts?: { isUser?: boolean, reload?: boolean }) => {
|
checkConfig: async (opts?: { isUser?: boolean, reload?: boolean }) => {
|
||||||
const { CNB_API_KEY } = get().config;
|
const { CNB_API_KEY } = get().config;
|
||||||
if (!CNB_API_KEY && opts?.isUser) {
|
if (!CNB_API_KEY && opts?.isUser) {
|
||||||
await get().loadFromRemote();
|
const res = await get().loadFromRemote();
|
||||||
if (opts?.reload) {
|
if (opts?.reload && res) {
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
return true
|
return res;
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ import { z } from 'zod';
|
|||||||
export const configSchema = z.object({
|
export const configSchema = z.object({
|
||||||
CNB_API_KEY: z.string().min(1, 'API Key is required'),
|
CNB_API_KEY: z.string().min(1, 'API Key is required'),
|
||||||
CNB_COOKIE: z.string().min(1, 'Cookie is required'),
|
CNB_COOKIE: z.string().min(1, 'Cookie is required'),
|
||||||
CNB_CORS_URL: z.url('Must be a valid URL'),
|
|
||||||
ENABLE_CORS: z.boolean(),
|
|
||||||
AI_BASE_URL: z.url('Must be a valid URL'),
|
|
||||||
AI_MODEL: z.string().min(1, 'AI Model is required'),
|
|
||||||
AI_API_KEY: z.string().min(1, 'AI API Key is required'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof configSchema>;
|
export type Config = z.infer<typeof configSchema>;
|
||||||
@@ -15,9 +10,4 @@ export type Config = z.infer<typeof configSchema>;
|
|||||||
export const defaultConfig: Config = {
|
export const defaultConfig: Config = {
|
||||||
CNB_API_KEY: '',
|
CNB_API_KEY: '',
|
||||||
CNB_COOKIE: '',
|
CNB_COOKIE: '',
|
||||||
CNB_CORS_URL: 'https://cors.kevisual.cn',
|
|
||||||
ENABLE_CORS: true,
|
|
||||||
AI_BASE_URL: '',
|
|
||||||
AI_MODEL: '',
|
|
||||||
AI_API_KEY: ''
|
|
||||||
};
|
};
|
||||||
|
|||||||
8
src/pages/demo/page.tsx
Normal file
8
src/pages/demo/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { useDemoStore } from './store/index'
|
||||||
|
export const App = () => {
|
||||||
|
const demoStore = useDemoStore()
|
||||||
|
console.log('demo', demoStore.formData)
|
||||||
|
return <div>App</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
95
src/pages/demo/store/index.ts
Normal file
95
src/pages/demo/store/index.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { query } from '@/modules/query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
id: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
formData: Record<string, any>;
|
||||||
|
setFormData: (data: Record<string, any>) => void;
|
||||||
|
showEdit: boolean;
|
||||||
|
setShowEdit: (showEdit: boolean) => void;
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
list: Data[];
|
||||||
|
getItem: (id: string) => Promise<any>;
|
||||||
|
getList: () => Promise<any>;
|
||||||
|
updateData: (data: Data) => Promise<void>;
|
||||||
|
deleteData: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDemoStore = create<State>((set, get) => {
|
||||||
|
return {
|
||||||
|
formData: {},
|
||||||
|
setFormData: (data) => set({ formData: data }),
|
||||||
|
showEdit: false,
|
||||||
|
setShowEdit: (showEdit) => set({ showEdit }),
|
||||||
|
loading: false,
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
list: [],
|
||||||
|
getItem: async (id) => {
|
||||||
|
const { setLoading } = get();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'demo',
|
||||||
|
key: 'item',
|
||||||
|
data: { id }
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getList: async () => {
|
||||||
|
const { setLoading } = get();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'demo',
|
||||||
|
key: 'list'
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
const list = res.data?.list || []
|
||||||
|
set({ list });
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateData: async (data) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'demo',
|
||||||
|
key: 'update',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
get().getList()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteData: async (id) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'demo',
|
||||||
|
key: 'delete',
|
||||||
|
data: { id }
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
get().getList()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1 +0,0 @@
|
|||||||
// gitea.tsx
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
import App from './repos/page'
|
import App from './repos/page'
|
||||||
|
export default App
|
||||||
export default App;
|
|
||||||
@@ -23,16 +23,20 @@ export const BuildConfig = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const me = useLayoutStore((state) => state.me);
|
const me = useLayoutStore((state) => state.me);
|
||||||
const [localConfig, setLocalConfig] = useState(repoStore.buildConfig?.config || "");
|
const [localConfig, setLocalConfig] = useState(repoStore.buildConfig?.config || "");
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
// 同步 buildConfig 变化时的状态
|
// 同步 buildConfig 变化时的状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalConfig(repoStore.buildConfig?.config || "");
|
setLocalConfig(repoStore.buildConfig?.config || "");
|
||||||
}, [repoStore.buildConfig]);
|
}, [repoStore.buildConfig]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (repo) {
|
if (repo) {
|
||||||
repoStore.initBuildConfig({ repo: repo, user: me });
|
repoStore.initBuildConfig({ repo: repo, user: me }).then(() => {
|
||||||
|
setMounted(true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setMounted(true);
|
||||||
}
|
}
|
||||||
}, [repo, me])
|
}, [repo])
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (repoStore.buildConfig) {
|
if (repoStore.buildConfig) {
|
||||||
@@ -51,27 +55,28 @@ export const BuildConfig = () => {
|
|||||||
}, false);
|
}, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (repoStore.loading) {
|
if (repoStore.loading || !mounted) {
|
||||||
return <div>Loading...</div>
|
return <div>Loading...</div>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 h-full overflow-hidden">
|
<div className="flex flex-col md:flex-row gap-3 md:gap-4 h-full overflow-hidden">
|
||||||
{/* 左侧边栏 - 配置信息 */}
|
{/* 左侧边栏 - 配置信息 */}
|
||||||
<div className="w-64 shrink-0 space-y-4">
|
<div className="w-full md:w-64 shrink-0 space-y-3 md:space-y-4 order-2 md:order-1">
|
||||||
<div className="text-xl font-bold border-b pb-2 mb-4 flex">
|
<div className="text-lg md:text-xl font-bold border-b pb-2 mb-3 md:mb-4 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/' })}
|
onClick={() => navigate({ to: '/' })}
|
||||||
className="cursor-pointer flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 transition-colors"
|
className="cursor-pointer flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 text-neutral-600" />
|
<ArrowLeft className="w-4 h-4 text-neutral-600" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-lg font-semibold">构建配置</span>
|
<span className="text-base md:text-lg font-semibold truncate">构建配置</span>
|
||||||
<button
|
<button
|
||||||
onClick={repoStore.buildWorkspace}
|
onClick={repoStore.buildWorkspace}
|
||||||
className="ml-auto p-2 text-sm cursor-pointer bg-gray-500 text-white rounded hover:bg-gray-600 flex items-center"
|
className="ml-auto p-1.5 md:p-2 text-xs md:text-sm cursor-pointer bg-gray-500 text-white rounded hover:bg-gray-600 flex items-center gap-1"
|
||||||
title="构建工作空间"
|
title="构建工作空间"
|
||||||
>
|
>
|
||||||
<Workflow className="w-4 h-4" />
|
<Workflow className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
|
<span className="hidden md:inline">构建</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -101,19 +106,19 @@ export const BuildConfig = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧 - 编辑器 */}
|
{/* 右侧 - 编辑器 */}
|
||||||
<div className="flex-1 flex flex-col h-full ">
|
<div className="flex-1 flex flex-col h-full order-1 md:order-2 min-h-[300px] md:min-h-0">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2 gap-2">
|
||||||
<span className="text-sm font-medium">配置文件</span>
|
<span className="text-sm font-medium">配置文件</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="px-3 cursor-pointer py-1 text-sm bg-primary text-white rounded hover:bg-primary/90"
|
className="px-2 md:px-3 cursor-pointer py-1 text-xs md:text-sm bg-primary text-white rounded hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => repoStore.deleteBuildConfig({ repo: repo, user: me })}
|
onClick={() => repoStore.deleteBuildConfig({ repo: repo, user: me })}
|
||||||
className="px-3 cursor-pointer py-1 text-sm bg-red-500 text-white rounded hover:bg-red-600"
|
className="px-2 md:px-3 cursor-pointer py-1 text-xs md:text-sm bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ import { useRepoStore } from '../store'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useShallow } from 'zustand/shallow'
|
import { useShallow } from 'zustand/shallow'
|
||||||
import { myOrgs } from '../store/build'
|
import { myOrgs } from '../store/build'
|
||||||
import { app, cnb } from '@/agents/app'
|
import { app } from '@/agents/app'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
interface RepoCardProps {
|
interface RepoCardProps {
|
||||||
repo: any
|
repo: any
|
||||||
@@ -46,8 +47,6 @@ export function RepoCard({ showReturn = false, repo }: RepoCardProps) {
|
|||||||
return store.workspaceList.find(ws => ws.slug === repo.path)
|
return store.workspaceList.find(ws => ws.slug === repo.path)
|
||||||
}, [store.workspaceList, repo.path])
|
}, [store.workspaceList, repo.path])
|
||||||
const isWorkspaceActive = !!workspace
|
const isWorkspaceActive = !!workspace
|
||||||
const owner = repo.path.split('/')[0]
|
|
||||||
const isMine = myOrgs.includes(owner)
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isKnowledge = repo?.flags === "KnowledgeBase"
|
const isKnowledge = repo?.flags === "KnowledgeBase"
|
||||||
const createKnow = async () => {
|
const createKnow = async () => {
|
||||||
@@ -71,33 +70,34 @@ export function RepoCard({ showReturn = false, repo }: RepoCardProps) {
|
|||||||
const handleIssue = (repo: any) => {
|
const handleIssue = (repo: any) => {
|
||||||
window.open(`https://cnb.cool/${repo.path}/-/issues`)
|
window.open(`https://cnb.cool/${repo.path}/-/issues`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSettings = (repo: any) => {
|
const handleSettings = (repo: any) => {
|
||||||
window.open(`https://cnb.cool/${repo.path}/-/settings`)
|
window.open(`https://cnb.cool/${repo.path}/-/settings`)
|
||||||
}
|
}
|
||||||
|
const openInCNB = (isDetail = true) => {
|
||||||
|
if (!showReturn && isDetail) {
|
||||||
|
navigate({ to: `/repo?repo=${repo.path}` })
|
||||||
|
} else {
|
||||||
|
window.open(`https://cnb.cool/${repo.path}`, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="relative p-0 overflow-hidden border border-neutral-200 bg-white hover:shadow-xl hover:border-neutral-300 transition-all duration-300 group pb-14">
|
<Card className="relative p-0 overflow-hidden border border-neutral-200 bg-white hover:shadow-xl hover:border-neutral-300 transition-all duration-300 group pb-12 md:pb-14">
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-4 md:p-6 space-y-3 md:space-y-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
{showReturn && (
|
{showReturn && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/' })}
|
onClick={() => navigate({ to: '/' })}
|
||||||
className="cursor-pointer flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 transition-colors"
|
className="cursor-pointer flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4 text-neutral-600" />
|
<ArrowLeft className="w-4 h-4 text-neutral-600" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className="text-lg font-bold text-neutral-900 hover:text-neutral-600 transition-colors line-clamp-1 group-hover:underline"
|
className="text-base md:text-lg font-bold text-neutral-900 hover:text-neutral-600 transition-colors line-clamp-1 group-hover:underline cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!showReturn) {
|
openInCNB()
|
||||||
navigate({ to: `/repo?repo=${repo.path}` })
|
|
||||||
} else {
|
|
||||||
window.open(`https://cnb.cool/${repo.path}`, '_blank')
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{repo.path}
|
{repo.path}
|
||||||
@@ -125,7 +125,7 @@ export function RepoCard({ showReturn = false, repo }: RepoCardProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-1 md:gap-2 shrink-0">
|
||||||
{isWorkspaceActive && (
|
{isWorkspaceActive && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -188,6 +188,12 @@ export function RepoCard({ showReturn = false, repo }: RepoCardProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DropdownMenuContent align="end" className="w-40">
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DropdownMenuItem onClick={() => {
|
||||||
|
window.open(repo.web_url, '_blank')
|
||||||
|
}} className="cursor-pointer">
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
访问仓库
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => {
|
<DropdownMenuItem onClick={() => {
|
||||||
store.setEditRepo(repo)
|
store.setEditRepo(repo)
|
||||||
store.setShowEditDialog(true)
|
store.setShowEditDialog(true)
|
||||||
@@ -220,12 +226,7 @@ export function RepoCard({ showReturn = false, repo }: RepoCardProps) {
|
|||||||
<Copy className="w-4 h-4 mr-2" />
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
Clone URL
|
Clone URL
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => {
|
|
||||||
window.open(repo.web_url, '_blank')
|
|
||||||
}} className="cursor-pointer">
|
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
|
||||||
访问仓库
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleIssue(repo)} className="cursor-pointer">
|
<DropdownMenuItem onClick={() => handleIssue(repo)} className="cursor-pointer">
|
||||||
<IssueIcon className="w-4 h-4 mr-2" />
|
<IssueIcon className="w-4 h-4 mr-2" />
|
||||||
访问问题
|
访问问题
|
||||||
@@ -285,7 +286,7 @@ export function RepoCard({ showReturn = false, repo }: RepoCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-1.5 md:gap-2">
|
||||||
{repo.topics && (<>
|
{repo.topics && (<>
|
||||||
{
|
{
|
||||||
repo.topics.split(',').map((topic: string, idx: number) => (
|
repo.topics.split(',').map((topic: string, idx: number) => (
|
||||||
@@ -298,59 +299,51 @@ export function RepoCard({ showReturn = false, repo }: RepoCardProps) {
|
|||||||
)}
|
)}
|
||||||
<Badge variant="outline" className="text-xs border-neutral-300 text-neutral-700 hover:bg-neutral-100 transition-colors">{repo.visibility_level}</Badge>
|
<Badge variant="outline" className="text-xs border-neutral-300 text-neutral-700 hover:bg-neutral-100 transition-colors">{repo.visibility_level}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={clsx(!showReturn && "cursor-pointer")} onClick={() => {
|
||||||
{repo.site && (
|
{ !showReturn && openInCNB(false) }
|
||||||
<div
|
}}>
|
||||||
className="text-xs text-neutral-500 hover:text-neutral-900 hover:underline flex transition-colors"
|
{repo.site && (
|
||||||
onClick={() => {
|
<div
|
||||||
window.open(repo.site, '_blank')
|
className="text-xs text-neutral-500 hover:text-neutral-900 hover:underline flex transition-colors"
|
||||||
}}
|
onClick={(e) => {
|
||||||
>
|
window.open(repo.site, '_blank')
|
||||||
<LinkIcon className="w-4 h-4 shrink-0 mr-2" />
|
e.stopPropagation()
|
||||||
<div className='truncate grow'>
|
}}
|
||||||
{repo.site}
|
>
|
||||||
|
<LinkIcon className="w-4 h-4 shrink-0 mr-2" />
|
||||||
|
<div className='truncate grow'>
|
||||||
|
{repo.site}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{repo.description && (
|
||||||
{repo.description && (
|
<p className="text-sm text-neutral-600 line-clamp-2 min-h-10">
|
||||||
<p className="ml-2 text-sm text-neutral-600 line-clamp-2 min-h-10 grow">
|
{repo.description}
|
||||||
{repo.description}
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-4 text-xs text-neutral-500 px-6 py-3 border-t border-neutral-100 bg-neutral-50">
|
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-2 md:gap-4 text-xs text-neutral-500 px-4 md:px-6 py-2 md:py-3 border-t border-neutral-100 bg-neutral-50 overflow-x-auto">
|
||||||
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
|
<span className="flex items-center gap-1 hover:text-neutral-900 transition-colors whitespace-nowrap">
|
||||||
<Star className="w-3.5 h-3.5" />
|
<Star className="w-3.5 h-3.5" />
|
||||||
<span className="font-medium">{repo.star_count}</span>
|
<span className="font-medium">{repo.star_count}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
|
<span className="flex items-center gap-1 hover:text-neutral-900 transition-colors whitespace-nowrap">
|
||||||
<GitFork className="w-3.5 h-3.5" />
|
<GitFork className="w-3.5 h-3.5" />
|
||||||
<span className="font-medium">{repo.fork_count}</span>
|
<span className="font-medium">{repo.fork_count}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
|
<span className="flex items-center gap-1 hover:text-neutral-900 transition-colors whitespace-nowrap">
|
||||||
<FileText className="w-3.5 h-3.5" />
|
<FileText className="w-3.5 h-3.5" />
|
||||||
<span className="font-medium">{repo.open_issue_count}</span>
|
<span className="font-medium">{repo.open_issue_count}</span>
|
||||||
</span>
|
</span>
|
||||||
{isWorkspaceActive && <span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors cursor-pointer"
|
{isWorkspaceActive && <span className="flex items-center gap-1 hover:text-neutral-900 transition-colors cursor-pointer whitespace-nowrap"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
store.getWorkspaceDetail(workspace)
|
store.getWorkspaceDetail(workspace)
|
||||||
}}>
|
}}>
|
||||||
<Play className="w-3.5 h-3.5" />
|
<Play className="w-3.5 h-3.5" />
|
||||||
<span className="font-medium">运行中</span>
|
<span className="font-medium">运行中</span>
|
||||||
</span>}
|
</span>}
|
||||||
{isMine && (
|
|
||||||
<span
|
|
||||||
className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
store.setSelectedSyncRepo(repo)
|
|
||||||
store.setSyncDialogOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
|
||||||
<span className="font-medium">同步</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -12,6 +12,13 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { useRepoStore } from '../store'
|
import { useRepoStore } from '../store'
|
||||||
import { useShallow } from 'zustand/shallow'
|
import { useShallow } from 'zustand/shallow'
|
||||||
|
|
||||||
@@ -32,7 +39,7 @@ export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps)
|
|||||||
createRepo: state.createRepo,
|
createRepo: state.createRepo,
|
||||||
refresh: state.refresh,
|
refresh: state.refresh,
|
||||||
})))
|
})))
|
||||||
const { register, handleSubmit, reset } = useForm<FormData>()
|
const { register, handleSubmit, reset, control } = useForm<FormData>()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,7 +58,10 @@ export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps)
|
|||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const submitData = {
|
const submitData = {
|
||||||
...data,
|
path: data.path.trim(),
|
||||||
|
license: data.license.trim(),
|
||||||
|
description: data.description.trim(),
|
||||||
|
visibility: data.visibility,
|
||||||
}
|
}
|
||||||
|
|
||||||
await createRepo(submitData)
|
await createRepo(submitData)
|
||||||
@@ -64,7 +74,7 @@ export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-150">
|
<DialogContent className="w-[90vw] max-w-lg sm:max-w-[525px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>新建仓库</DialogTitle>
|
<DialogTitle>新建仓库</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -87,17 +97,29 @@ export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps)
|
|||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
placeholder="简短描述你的仓库..."
|
placeholder="简短描述你的仓库..."
|
||||||
rows={3}
|
rows={2}
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="visibility">可见性</Label>
|
<Label htmlFor="visibility">可见性</Label>
|
||||||
<Input
|
<Controller
|
||||||
id="visibility"
|
name="visibility"
|
||||||
placeholder="public 或 private"
|
control={control}
|
||||||
{...register('visibility')}
|
defaultValue="public"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Select onValueChange={onChange} value={value}>
|
||||||
|
<SelectTrigger id="visibility">
|
||||||
|
<SelectValue placeholder="选择可见性" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="public">公开 (public)</SelectItem>
|
||||||
|
<SelectItem value="private">私有 (private)</SelectItem>
|
||||||
|
<SelectItem value="protected">保护 (protected)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,16 +132,17 @@ export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="flex-col sm:flex-row gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||||
{isSubmitting ? '创建中...' : '创建仓库'}
|
{isSubmitting ? '创建中...' : '创建仓库'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function EditRepoDialog({ open, onOpenChange, repo }: EditRepoDialogProps
|
|||||||
path: repo.path,
|
path: repo.path,
|
||||||
description: data.description?.trim() || '',
|
description: data.description?.trim() || '',
|
||||||
site: data.site?.trim() || '',
|
site: data.site?.trim() || '',
|
||||||
topics: tags.join(','),
|
topics: tags as any,
|
||||||
license: data.license?.trim() || '',
|
license: data.license?.trim() || '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,21 +76,21 @@ export function EditRepoDialog({ open, onOpenChange, repo }: EditRepoDialogProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl!">
|
<DialogContent className="w-[90vw] max-w-2xl! max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>编辑仓库信息</DialogTitle>
|
<DialogTitle>编辑仓库信息</DialogTitle>
|
||||||
<DialogDescription>{repo.path}</DialogDescription>
|
<DialogDescription className="text-sm truncate">{repo.path}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 md:space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">描述</Label>
|
<Label htmlFor="description">描述</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
placeholder="输入仓库描述"
|
placeholder="输入仓库描述"
|
||||||
className="w-full min-h-[100px]"
|
className="w-full min-h-[80px] md:min-h-[100px]"
|
||||||
rows={4}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,15 +125,16 @@ export function EditRepoDialog({ open, onOpenChange, repo }: EditRepoDialogProps
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="flex-col sm:flex-row gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">
|
<Button type="submit" className="w-full sm:w-auto">
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { useRepoStore } from '../store'
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useShallow } from 'zustand/shallow'
|
|
||||||
import { get, set } from 'idb-keyval'
|
|
||||||
import { gitea } from '@/agents/app';
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
const SYNC_REPO_STORAGE_KEY = 'sync-repo-mapping'
|
|
||||||
|
|
||||||
export function SyncRepoDialog() {
|
|
||||||
const { syncDialogOpen, setSyncDialogOpen, selectedSyncRepo, buildSync } = useRepoStore(useShallow((state) => ({
|
|
||||||
syncDialogOpen: state.syncDialogOpen,
|
|
||||||
setSyncDialogOpen: state.setSyncDialogOpen,
|
|
||||||
selectedSyncRepo: state.selectedSyncRepo,
|
|
||||||
buildSync: state.buildSync,
|
|
||||||
})))
|
|
||||||
const [toRepo, setToRepo] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadSavedMapping = async () => {
|
|
||||||
if (syncDialogOpen && selectedSyncRepo) {
|
|
||||||
const currentPath = selectedSyncRepo.path || ''
|
|
||||||
// 从 idb-keyval 获取存储的映射
|
|
||||||
const mapping = await get<Record<string, string>>(SYNC_REPO_STORAGE_KEY)
|
|
||||||
// 如果有存储的值,使用存储的值,否则使用当前仓库路径
|
|
||||||
setToRepo(mapping?.[currentPath] || currentPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadSavedMapping()
|
|
||||||
}, [syncDialogOpen, selectedSyncRepo])
|
|
||||||
|
|
||||||
const handleSync = async () => {
|
|
||||||
if (!selectedSyncRepo || !toRepo.trim()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存映射到 idb-keyval
|
|
||||||
const currentPath = selectedSyncRepo.path || ''
|
|
||||||
const mapping = await get<Record<string, string>>(SYNC_REPO_STORAGE_KEY) || {}
|
|
||||||
mapping[currentPath] = toRepo
|
|
||||||
await set(SYNC_REPO_STORAGE_KEY, mapping)
|
|
||||||
|
|
||||||
await buildSync(selectedSyncRepo, { toRepo })
|
|
||||||
setSyncDialogOpen(false)
|
|
||||||
}
|
|
||||||
const onCreateRepo = async () => {
|
|
||||||
if (!toRepo.trim()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await gitea.repo.createRepo({ name: toRepo })
|
|
||||||
if (res.code !== 200 && res.code !== 409) {
|
|
||||||
// 409 表示仓库已存在,可以继续同步
|
|
||||||
throw new Error(`${res.message}`)
|
|
||||||
}
|
|
||||||
if (res.code === 200) {
|
|
||||||
toast.success('仓库创建成功,正在同步...')
|
|
||||||
} else {
|
|
||||||
toast.warning('仓库已存在,正在同步...')
|
|
||||||
}
|
|
||||||
handleSync()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('创建仓库失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-125">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>同步仓库到 Gitea</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
将仓库 <span className="font-semibold text-neutral-900">{selectedSyncRepo?.path}</span> 同步到目标仓库
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="toRepo">目标仓库路径</Label>
|
|
||||||
<Input
|
|
||||||
id="toRepo"
|
|
||||||
placeholder="例如: kevisual/my-repo"
|
|
||||||
value={toRepo}
|
|
||||||
onChange={(e) => setToRepo(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-neutral-500">
|
|
||||||
格式: owner/repo-name
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setSyncDialogOpen(false)}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onCreateRepo} disabled={!toRepo.trim()}>
|
|
||||||
先创建仓库再同步
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSync}
|
|
||||||
disabled={!toRepo.trim()}
|
|
||||||
>
|
|
||||||
开始同步
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,37 @@
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipProvider
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
import { useRepoStore } from '../store'
|
import { useRepoStore } from '../store'
|
||||||
import type { WorkspaceOpen } from '../store'
|
import type { WorkspaceOpen } from '../store'
|
||||||
import {
|
import {
|
||||||
Code2,
|
Code2,
|
||||||
Terminal,
|
Terminal,
|
||||||
Sparkles,
|
|
||||||
MousePointer2,
|
MousePointer2,
|
||||||
Box,
|
|
||||||
Lock,
|
Lock,
|
||||||
Radio,
|
Radio,
|
||||||
Bot,
|
|
||||||
Zap,
|
Zap,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
Square
|
Square,
|
||||||
|
Link,
|
||||||
|
ExternalLink,
|
||||||
|
Wind,
|
||||||
|
Plane,
|
||||||
|
Rocket
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useShallow } from 'zustand/shallow'
|
import { useShallow } from 'zustand/shallow'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
type LinkItemKey = keyof WorkspaceOpen;
|
type LinkItemKey = keyof WorkspaceOpen;
|
||||||
interface LinkItem {
|
interface LinkItem {
|
||||||
@@ -35,7 +43,6 @@ interface LinkItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode; url?: string }) => {
|
const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode; url?: string }) => {
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
|
||||||
const [isCopied, setIsCopied] = useState(false)
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
@@ -43,7 +50,7 @@ const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode;
|
|||||||
copy()
|
copy()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (url) {
|
if (url && url.includes(':')) {
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,41 +64,38 @@ const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode;
|
|||||||
toast.error('复制失败')
|
toast.error('复制失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handleCopy = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (!url) return
|
|
||||||
copy()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<TooltipProvider delay={200}>
|
||||||
onClick={handleClick}
|
<Tooltip>
|
||||||
disabled={!url}
|
<TooltipTrigger>
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
<div
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onClick={handleClick}
|
||||||
className="relative flex items-center gap-3 p-3 rounded-lg border border-neutral-200 hover:border-neutral-900 hover:bg-neutral-50 transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-neutral-200 disabled:hover:bg-transparent group"
|
className="relative flex items-center gap-3 p-3 rounded-lg border border-neutral-200 hover:border-neutral-900 hover:bg-neutral-50 transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-neutral-200 disabled:hover:bg-transparent group"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 flex items-center justify-center text-neutral-700">
|
<div className="w-8 h-8 flex items-center justify-center text-neutral-700">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-neutral-900 flex-1 text-left truncate">{label}</span>
|
<span className="text-sm font-medium text-neutral-900 flex-1 text-left truncate">{label}</span>
|
||||||
{url && isHovered && (
|
{url && (
|
||||||
<div
|
<div
|
||||||
onClick={handleCopy}
|
onClick={(e) => {
|
||||||
role="button"
|
e.stopPropagation()
|
||||||
tabIndex={0}
|
copy()
|
||||||
onKeyDown={(e) => {
|
}}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
role="button"
|
||||||
e.preventDefault()
|
className="w-6 h-6 flex items-center justify-center text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 rounded transition-colors cursor-pointer"
|
||||||
handleCopy(e as any)
|
>
|
||||||
}
|
{isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
}}
|
</div>
|
||||||
className="w-6 h-6 flex items-center justify-center text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 rounded transition-colors cursor-pointer"
|
)}
|
||||||
>
|
</div>
|
||||||
{isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
</TooltipTrigger>
|
||||||
</div>
|
<TooltipContent>
|
||||||
)}
|
<p>{url}</p>
|
||||||
</button>
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,9 +128,40 @@ const DevTabContent = ({ linkItems, workspaceLink, stopWorkspace }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Link tab 内容(暂留空)
|
||||||
|
const LinkTabContent = () => {
|
||||||
|
const store = useRepoStore(useShallow((state) => ({
|
||||||
|
selectWorkspace: state.selectWorkspace,
|
||||||
|
workspaceSecretLink: state.workspaceSecretLink,
|
||||||
|
})))
|
||||||
|
const links = store.workspaceSecretLink.map(item => ({
|
||||||
|
label: item.title,
|
||||||
|
url: item.value
|
||||||
|
}))
|
||||||
|
if (links.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-neutral-400">
|
||||||
|
暂无链接, 或工作区未启动
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-neutral-400">
|
||||||
|
<div className="grid grid-cols-1 gap-3 w-full max-w-sm">
|
||||||
|
{links.map(link => (
|
||||||
|
<LinkItem key={link.label} label={link.label} icon={<Link className="w-5 h-5" />} url={link.url} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Work tab 内容(暂留,需要根据 business_id 做事情)
|
// Work tab 内容(暂留,需要根据 business_id 做事情)
|
||||||
const WorkTabContent = () => {
|
const WorkTabContent = () => {
|
||||||
const store = useRepoStore(useShallow((state) => ({ selectWorkspace: state.selectWorkspace })))
|
const store = useRepoStore(useShallow((state) => ({
|
||||||
|
selectWorkspace: state.selectWorkspace,
|
||||||
|
workspaceLink: state.workspaceLink,
|
||||||
|
})))
|
||||||
const businessId = store.selectWorkspace?.business_id;
|
const businessId = store.selectWorkspace?.business_id;
|
||||||
|
|
||||||
const appList = [
|
const appList = [
|
||||||
@@ -139,11 +174,23 @@ const WorkTabContent = () => {
|
|||||||
{
|
{
|
||||||
title: 'OpenClaw', key: 'OpenClaw', port: 80, end: '/openclaw'
|
title: 'OpenClaw', key: 'OpenClaw', port: 80, end: '/openclaw'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'vscode' as LinkItemKey,
|
||||||
|
title: 'VS Code',
|
||||||
|
icon: <Code2 className="w-5 h-5" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'OpenWebUI', key: 'OpenWebUI', port: 200, end: ''
|
title: 'OpenWebUI', key: 'OpenWebUI', port: 200, end: ''
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const links = appList.map(app => {
|
const links = appList.map(app => {
|
||||||
|
if (app.icon) {
|
||||||
|
return {
|
||||||
|
label: app.title,
|
||||||
|
icon: app.icon,
|
||||||
|
url: store?.workspaceLink?.[app.key as LinkItemKey] as string | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
const url = `https://${businessId}-${app.port}.cnb.run${app.end}`
|
const url = `https://${businessId}-${app.port}.cnb.run${app.end}`
|
||||||
return {
|
return {
|
||||||
label: app.title,
|
label: app.title,
|
||||||
@@ -163,7 +210,7 @@ const WorkTabContent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceDetailDialog() {
|
export function WorkspaceDetailDialog() {
|
||||||
const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink, stopWorkspace, workspaceTab, setWorkspaceTab, selectWorkspace } = useRepoStore(useShallow((state) => ({
|
const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink, stopWorkspace, workspaceTab, setWorkspaceTab, getWorkspaceSecretLink, selectWorkspace, workspaceSecretLink } = useRepoStore(useShallow((state) => ({
|
||||||
showWorkspaceDialog: state.showWorkspaceDialog,
|
showWorkspaceDialog: state.showWorkspaceDialog,
|
||||||
setShowWorkspaceDialog: state.setShowWorkspaceDialog,
|
setShowWorkspaceDialog: state.setShowWorkspaceDialog,
|
||||||
workspaceLink: state.workspaceLink,
|
workspaceLink: state.workspaceLink,
|
||||||
@@ -171,72 +218,86 @@ export function WorkspaceDetailDialog() {
|
|||||||
workspaceTab: state.workspaceTab,
|
workspaceTab: state.workspaceTab,
|
||||||
setWorkspaceTab: state.setWorkspaceTab,
|
setWorkspaceTab: state.setWorkspaceTab,
|
||||||
selectWorkspace: state.selectWorkspace,
|
selectWorkspace: state.selectWorkspace,
|
||||||
|
getWorkspaceSecretLink: state.getWorkspaceSecretLink,
|
||||||
|
workspaceSecretLink: state.workspaceSecretLink
|
||||||
})))
|
})))
|
||||||
const linkItems: LinkItem[] = [
|
const linkItems: LinkItem[] = [
|
||||||
|
{
|
||||||
|
key: 'jumpUrl' as LinkItemKey,
|
||||||
|
label: 'Jump',
|
||||||
|
icon: <ExternalLink className="w-5 h-5" />,
|
||||||
|
order: 1,
|
||||||
|
getUrl: (data) => data.jumpUrl
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'webide' as LinkItemKey,
|
key: 'webide' as LinkItemKey,
|
||||||
label: 'Web IDE',
|
label: 'Web IDE',
|
||||||
icon: <Code2 className="w-5 h-5" />,
|
icon: <Code2 className="w-5 h-5" />,
|
||||||
order: 1,
|
order: 2,
|
||||||
getUrl: (data) => data.webide
|
getUrl: (data) => data.webide
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'vscode' as LinkItemKey,
|
key: 'vscode' as LinkItemKey,
|
||||||
label: 'VS Code',
|
label: 'VS Code',
|
||||||
icon: <Code2 className="w-5 h-5" />,
|
icon: <Code2 className="w-5 h-5" />,
|
||||||
order: 2,
|
order: 3,
|
||||||
getUrl: (data) => data.vscode
|
getUrl: (data) => data.vscode
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'vscode-insiders' as LinkItemKey,
|
|
||||||
label: 'VS Code Insiders',
|
|
||||||
icon: <Sparkles className="w-5 h-5" />,
|
|
||||||
order: 5,
|
|
||||||
getUrl: (data) => data['vscode-insiders']
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'cursor' as LinkItemKey,
|
key: 'cursor' as LinkItemKey,
|
||||||
label: 'Cursor',
|
label: 'Cursor',
|
||||||
icon: <MousePointer2 className="w-5 h-5" />,
|
icon: <MousePointer2 className="w-5 h-5" />,
|
||||||
order: 6,
|
order: 4,
|
||||||
getUrl: (data) => data.cursor
|
getUrl: (data) => data.cursor
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'jetbrains' as LinkItemKey,
|
key: 'trae-cn' as LinkItemKey,
|
||||||
label: 'JetBrains IDEs',
|
label: 'Trae',
|
||||||
icon: <Box className="w-5 h-5" />,
|
icon: <Rocket className="w-5 h-5" />,
|
||||||
|
order: 5,
|
||||||
|
getUrl: (data) => data['trae-cn']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'windsurf' as LinkItemKey,
|
||||||
|
label: 'Windsurf',
|
||||||
|
icon: <Wind className="w-5 h-5" />,
|
||||||
|
order: 6,
|
||||||
|
getUrl: (data) => data.windsurf
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'antigravity' as LinkItemKey,
|
||||||
|
label: 'Antigravity',
|
||||||
|
icon: <Plane className="w-5 h-5" />,
|
||||||
order: 7,
|
order: 7,
|
||||||
getUrl: (data) => Object.values(data.jetbrains || {}).find(Boolean)
|
getUrl: (data) => data.antigravity
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'ssh' as LinkItemKey,
|
key: 'ssh' as LinkItemKey,
|
||||||
label: 'SSH',
|
label: 'SSH',
|
||||||
icon: <Lock className="w-5 h-5" />,
|
icon: <Lock className="w-5 h-5" />,
|
||||||
order: 4,
|
order: 9,
|
||||||
getUrl: (data) => data.ssh
|
getUrl: (data) => data.ssh
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'remoteSsh' as LinkItemKey,
|
key: 'remoteSsh' as LinkItemKey,
|
||||||
label: 'Remote SSH',
|
label: 'Remote SSH',
|
||||||
icon: <Radio className="w-5 h-5" />,
|
icon: <Radio className="w-5 h-5" />,
|
||||||
order: 8,
|
order: 10,
|
||||||
getUrl: (data) => data.remoteSsh
|
getUrl: (data) => data.remoteSsh
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'codebuddy' as LinkItemKey,
|
|
||||||
label: 'CodeBuddy',
|
|
||||||
icon: <Bot className="w-5 h-5" />,
|
|
||||||
order: 9,
|
|
||||||
getUrl: (data) => data.codebuddy
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'codebuddycn' as LinkItemKey,
|
key: 'codebuddycn' as LinkItemKey,
|
||||||
label: 'CodeBuddy CN',
|
label: 'CodeBuddy',
|
||||||
icon: <Zap className="w-5 h-5" />,
|
icon: <Zap className="w-5 h-5" />,
|
||||||
order: 3,
|
order: 11,
|
||||||
getUrl: (data) => data.codebuddycn
|
getUrl: (data) => data.codebuddycn
|
||||||
},
|
},
|
||||||
].sort((a, b) => (a.order || 0) - (b.order || 0))
|
].sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectWorkspace) {
|
||||||
|
getWorkspaceSecretLink(selectWorkspace)
|
||||||
|
}
|
||||||
|
}, [selectWorkspace])
|
||||||
return (
|
return (
|
||||||
<Dialog open={showWorkspaceDialog} onOpenChange={setShowWorkspaceDialog}>
|
<Dialog open={showWorkspaceDialog} onOpenChange={setShowWorkspaceDialog}>
|
||||||
<DialogContent className="max-w-md! bg-white">
|
<DialogContent className="max-w-md! bg-white">
|
||||||
@@ -269,12 +330,29 @@ export function WorkspaceDetailDialog() {
|
|||||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setWorkspaceTab('link')}
|
||||||
|
className={clsx(`cursor-pointer flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${workspaceTab === 'link'
|
||||||
|
? 'text-neutral-900'
|
||||||
|
: 'text-neutral-500 hover:text-neutral-700'
|
||||||
|
}`)}
|
||||||
|
>
|
||||||
|
<Link className="w-4 h-4 inline-block mr-1" />
|
||||||
|
Link
|
||||||
|
{workspaceTab === 'link' && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Tab 内容 */}
|
{/* Tab 内容 */}
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
{workspaceTab === 'dev' ? (
|
{workspaceTab === 'dev' && (
|
||||||
<DevTabContent linkItems={linkItems} workspaceLink={workspaceLink} stopWorkspace={stopWorkspace} />
|
<DevTabContent linkItems={linkItems} workspaceLink={workspaceLink} stopWorkspace={stopWorkspace} />
|
||||||
) : (
|
)}
|
||||||
|
{workspaceTab === 'link' && (
|
||||||
|
<LinkTabContent />
|
||||||
|
)}
|
||||||
|
{workspaceTab === 'work' && (
|
||||||
<WorkTabContent />
|
<WorkTabContent />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { RepoCard } from './components/RepoCard'
|
|||||||
import { EditRepoDialog } from './modules/EditRepoDialog'
|
import { EditRepoDialog } from './modules/EditRepoDialog'
|
||||||
import { CreateRepoDialog } from './modules/CreateRepoDialog'
|
import { CreateRepoDialog } from './modules/CreateRepoDialog'
|
||||||
import { WorkspaceDetailDialog } from './modules/WorkspaceDetailDialog'
|
import { WorkspaceDetailDialog } from './modules/WorkspaceDetailDialog'
|
||||||
import { SyncRepoDialog } from './modules/SyncRepoDialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { ExternalLinkIcon, Plus, RefreshCw, Search, Settings } from 'lucide-react'
|
import { ExternalLinkIcon, Plus, RefreshCw, Search, Settings } from 'lucide-react'
|
||||||
@@ -13,6 +12,8 @@ import Fuse from 'fuse.js'
|
|||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { useLayoutStore } from '../auth/store'
|
import { useLayoutStore } from '../auth/store'
|
||||||
import { useConfigStore } from '../config/store'
|
import { useConfigStore } from '../config/store'
|
||||||
|
import { SidebarLayout } from '../sidebar/components'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const { list, refresh, loading, workspaceList, setShowCreateDialog } = useRepoStore(useShallow((state) => ({
|
const { list, refresh, loading, workspaceList, setShowCreateDialog } = useRepoStore(useShallow((state) => ({
|
||||||
@@ -23,21 +24,16 @@ export const App = () => {
|
|||||||
setShowCreateDialog: state.setShowCreateDialog,
|
setShowCreateDialog: state.setShowCreateDialog,
|
||||||
})))
|
})))
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [filterDev, setFilterDev] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('repos-filter-dev')
|
||||||
|
return saved === 'true'
|
||||||
|
})
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const me = useLayoutStore(state => state.me)
|
|
||||||
const configStore = useConfigStore(useShallow(state => ({ checkConfig: state.checkConfig })))
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh({ showTips: false })
|
refresh({ showTips: false })
|
||||||
}, [])
|
}, [])
|
||||||
useEffect(() => {
|
|
||||||
if (me && me.id) {
|
|
||||||
configStore.checkConfig({ isUser: true, reload: true })
|
|
||||||
}
|
|
||||||
}, [me])
|
|
||||||
|
|
||||||
|
|
||||||
const appList = useMemo(() => {
|
const appList = useMemo(() => {
|
||||||
// 首先按活动状态排序
|
|
||||||
const sortedList = [...list].sort((a, b) => {
|
const sortedList = [...list].sort((a, b) => {
|
||||||
const aActive = workspaceList.some(ws => ws.slug === a.path)
|
const aActive = workspaceList.some(ws => ws.slug === a.path)
|
||||||
const bActive = workspaceList.some(ws => ws.slug === b.path)
|
const bActive = workspaceList.some(ws => ws.slug === b.path)
|
||||||
@@ -47,13 +43,19 @@ export const App = () => {
|
|||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果没有搜索词,返回排序后的列表
|
let filteredList = sortedList
|
||||||
if (!searchQuery.trim()) {
|
if (filterDev) {
|
||||||
return sortedList
|
filteredList = sortedList.filter(repo => {
|
||||||
|
const topics = repo.topics ? repo.topics.split(',').map(t => t.trim().toLowerCase()) : []
|
||||||
|
return topics.some(topic => topic.includes('dev'))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Fuse.js 进行模糊搜索
|
if (!searchQuery.trim()) {
|
||||||
const fuse = new Fuse(sortedList, {
|
return filteredList
|
||||||
|
}
|
||||||
|
|
||||||
|
const fuse = new Fuse(filteredList, {
|
||||||
keys: ['name', 'path', 'description'],
|
keys: ['name', 'path', 'description'],
|
||||||
threshold: 0.3,
|
threshold: 0.3,
|
||||||
includeScore: true
|
includeScore: true
|
||||||
@@ -61,95 +63,124 @@ export const App = () => {
|
|||||||
|
|
||||||
const results = fuse.search(searchQuery)
|
const results = fuse.search(searchQuery)
|
||||||
return results.map(result => result.item)
|
return results.map(result => result.item)
|
||||||
}, [list, workspaceList, searchQuery])
|
}, [list, workspaceList, searchQuery, filterDev])
|
||||||
|
|
||||||
const isCNB = location.hostname.includes('cnb.run')
|
const isCNB = location.hostname.includes('cnb.run')
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50 flex flex-col">
|
<SidebarLayout>
|
||||||
<div className="container mx-auto p-6 max-w-7xl flex-1">
|
<div className="min-h-screen bg-neutral-50 flex flex-col">
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="container mx-auto p-4 md:p-6 max-w-7xl flex-1">
|
||||||
<div >
|
<div className="mb-6 md:mb-8 flex flex-col gap-4">
|
||||||
<h1 className="text-4xl font-bold text-neutral-900 mb-2 flex gap-2 items-center">
|
<div className="flex flex-col sm:flex-row sm:justify-between gap-3">
|
||||||
仓库列表
|
<div className=''>
|
||||||
<Settings className="inline-block h-5 w-5 text-neutral-400 hover:text-neutral-600 cursor-pointer" onClick={() => navigate({ to: '/config' })} />
|
<div className="flex items-center justify-between">
|
||||||
</h1>
|
<h1 className="text-2xl md:text-4xl font-bold text-neutral-900 flex gap-2 items-center">
|
||||||
<p className="text-neutral-600">共 {list.length} 个仓库</p>
|
<span className="hidden md:inline">仓库列表</span>
|
||||||
</div>
|
<span className="md:hidden">仓库</span>
|
||||||
<div className="flex items-center gap-2">
|
<Settings className="inline-block h-5 w-5 text-neutral-400 hover:text-neutral-600 cursor-pointer" onClick={() => navigate({ to: '/config' })} />
|
||||||
<Button
|
</h1>
|
||||||
onClick={() => refresh()}
|
</div>
|
||||||
variant="outline"
|
<p className="text-neutral-600 text-sm md:text-base">
|
||||||
className="gap-2"
|
{filterDev ? `显示 ${appList.length} 个 dev 仓库` : `共 ${list.length} 个仓库`}
|
||||||
>
|
</p>
|
||||||
<RefreshCw className="h-4 w-4" />
|
</div>
|
||||||
刷新
|
<div className="flex flex-wrap items-center gap-2 md:ml-auto">
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
onClick={() => refresh()}
|
||||||
onClick={() => setShowCreateDialog(true)}
|
variant="outline"
|
||||||
className="gap-2"
|
className="gap-2 flex-1 sm:flex-none"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
新建仓库
|
<span className="hidden sm:inline">刷新</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowCreateDialog(true)}
|
||||||
|
className="gap-2 flex-1 sm:flex-none"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">新建仓库</span>
|
||||||
|
<span className="sm:hidden">新建</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{isCNB && <Button
|
{isCNB && <Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.open('/root/cli-center', '_blank')
|
window.open('/root/cli-center', '_blank')
|
||||||
}}
|
}}
|
||||||
className="gap-2"
|
className="gap-2 hidden md:flex"
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="h-4 w-4" />
|
<ExternalLinkIcon className="h-4 w-4" />
|
||||||
CLI
|
CLI
|
||||||
</Button>}
|
</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索仓库名称、路径或描述..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{appList.map((repo) => (
|
|
||||||
<RepoCard
|
|
||||||
key={repo.id}
|
|
||||||
repo={repo}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{appList.length === 0 && !loading && (
|
|
||||||
<div className="text-center py-20">
|
|
||||||
<div className="text-neutral-400 text-lg">
|
|
||||||
{searchQuery ? '未找到匹配的仓库' : '暂无仓库数据'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="mb-4 md:mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索仓库..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="filter-dev"
|
||||||
|
checked={filterDev}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const value = checked === true
|
||||||
|
setFilterDev(value)
|
||||||
|
localStorage.setItem('repos-filter-dev', String(value))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="filter-dev"
|
||||||
|
className="text-sm text-neutral-600 cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
过滤 dev
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||||
|
{appList.map((repo) => (
|
||||||
|
<RepoCard
|
||||||
|
key={repo.id}
|
||||||
|
repo={repo}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{appList.length === 0 && !loading && (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<div className="text-neutral-400 text-lg">
|
||||||
|
{searchQuery ? '未找到匹配的仓库' : '暂无仓库数据'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="border-t border-neutral-200 bg-white py-4 md:py-6 mt-auto">
|
||||||
|
<div className="container mx-auto px-4 md:px-6 max-w-7xl">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-2 md:gap-4 text-sm text-neutral-500">
|
||||||
|
<div>© 2026 仓库管理系统</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a href="#" className="hover:text-neutral-900 transition-colors">关于</a>
|
||||||
|
<a href="#" className="hover:text-neutral-900 transition-colors">帮助</a>
|
||||||
|
<a href="#" className="hover:text-neutral-900 transition-colors">联系我们</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<CommonRepoDialog />
|
||||||
</div>
|
</div>
|
||||||
|
</SidebarLayout>
|
||||||
<footer className="border-t border-neutral-200 bg-white py-6 mt-auto">
|
|
||||||
<div className="container mx-auto px-6 max-w-7xl">
|
|
||||||
<div className="flex items-center justify-between text-sm text-neutral-500">
|
|
||||||
<div>© 2026 仓库管理系统</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<a href="#" className="hover:text-neutral-900 transition-colors">关于</a>
|
|
||||||
<a href="#" className="hover:text-neutral-900 transition-colors">帮助</a>
|
|
||||||
<a href="#" className="hover:text-neutral-900 transition-colors">联系我们</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<CommonRepoDialog />
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +204,6 @@ export const CommonRepoDialog = () => {
|
|||||||
onOpenChange={setShowCreateDialog}
|
onOpenChange={setShowCreateDialog}
|
||||||
/>
|
/>
|
||||||
<WorkspaceDetailDialog />
|
<WorkspaceDetailDialog />
|
||||||
<SyncRepoDialog />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { useShallow } from "zustand/shallow";
|
|||||||
import BuildConfig from "../components/BuildConfig";
|
import BuildConfig from "../components/BuildConfig";
|
||||||
import { CommonRepoDialog } from "../page";
|
import { CommonRepoDialog } from "../page";
|
||||||
import { RepoCard } from "../components/RepoCard";
|
import { RepoCard } from "../components/RepoCard";
|
||||||
|
import { SidebarLayout } from "@/pages/sidebar/components";
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const params = useSearch({ strict: false }) as { repo?: string, tab?: string };
|
const params = useSearch({ strict: false }) as { repo?: string, tab?: string };
|
||||||
const repoStore = useRepoStore(useShallow((state) => ({
|
const repoStore = useRepoStore(useShallow((state) => ({
|
||||||
getItem: state.getItem,
|
getItem: state.getItem,
|
||||||
editRepo: state.editRepo,
|
editRepo: state.editRepo,
|
||||||
refresH: state.refresh,
|
refresh: state.refresh,
|
||||||
|
loading: state.loading,
|
||||||
|
buildConfig: state.buildConfig,
|
||||||
})));
|
})));
|
||||||
const [activeTab, setActiveTab] = useState(params.tab || "build");
|
const [activeTab, setActiveTab] = useState(params.tab || "build");
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -20,8 +23,12 @@ export const App = () => {
|
|||||||
]
|
]
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (params.repo) {
|
if (params.repo) {
|
||||||
repoStore.getItem(params.repo);
|
if(repoStore.buildConfig?.repo !== params.repo) {
|
||||||
repoStore.refresH({ search: params.repo, showTips: false });
|
repoStore.getItem(params.repo);
|
||||||
|
}
|
||||||
|
console.log('refreshing repo',repoStore.buildConfig, params.repo)
|
||||||
|
// repoStore.getItem(params.repo);
|
||||||
|
repoStore.refresh({ search: params.repo, showTips: false });
|
||||||
} else {
|
} else {
|
||||||
console.log('no repo param')
|
console.log('no repo param')
|
||||||
}
|
}
|
||||||
@@ -30,34 +37,36 @@ export const App = () => {
|
|||||||
return <div>Loading...</div>
|
return <div>Loading...</div>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="p-2 flex-col flex gap-2 h-full">
|
<SidebarLayout>
|
||||||
<div className="px-4 h-full scrollbar flex-col flex gap-4 overflow-hidden">
|
<div className="p-2 md:p-4 flex-col flex gap-2 md:gap-4 h-full">
|
||||||
<div className="flex border-b mb-4">
|
<div className="px-2 md:px-4 h-full scrollbar flex-col flex gap-3 md:gap-4 overflow-hidden">
|
||||||
{tabs.map(tab => (
|
<div className="flex border-b overflow-x-auto h-12 shrink-0">
|
||||||
<div
|
{tabs.map(tab => (
|
||||||
key={tab.key}
|
<div
|
||||||
className={`px-4 py-2 cursor-pointer ${activeTab === tab.key ? 'border-b-2 border-gray-500' : ''}`}
|
key={tab.key}
|
||||||
onClick={() => {
|
className={`px-3 md:px-4 py-2 cursor-pointer whitespace-nowrap text-sm md:text-base ${activeTab === tab.key ? 'border-b-2 border-gray-500 font-medium' : ''}`}
|
||||||
setActiveTab(tab.key)
|
onClick={() => {
|
||||||
history.replaceState(null, '', `?repo=${params.repo}&tab=${tab.key}`)
|
setActiveTab(tab.key)
|
||||||
}}
|
history.replaceState(null, '', `?repo=${params.repo}&tab=${tab.key}`)
|
||||||
>
|
}}
|
||||||
{tab.label}
|
>
|
||||||
</div>
|
{tab.label}
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
{activeTab === 'build' && <BuildConfig />}
|
|
||||||
{activeTab === 'info' && (
|
|
||||||
<div className="flex flex-col gap-4 h-full">
|
|
||||||
<RepoCard repo={repoStore.editRepo} showReturn />
|
|
||||||
<div className="p-4 border rounded bg-white h-full overflow-auto scrollbar">
|
|
||||||
<pre className="whitespace-pre-wrap break-all">{JSON.stringify(repoStore.editRepo, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{activeTab === 'build' && <BuildConfig />}
|
||||||
|
{activeTab === 'info' && (
|
||||||
|
<div className="flex flex-col gap-3 md:gap-4 h-full">
|
||||||
|
<RepoCard repo={repoStore.editRepo} showReturn />
|
||||||
|
<div className="p-3 md:p-4 border rounded bg-white h-full overflow-auto scrollbar">
|
||||||
|
<pre className="whitespace-pre-wrap break-all text-xs md:text-sm">{JSON.stringify(repoStore.editRepo, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CommonRepoDialog />
|
||||||
</div>
|
</div>
|
||||||
<CommonRepoDialog />
|
</SidebarLayout>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,32 +53,15 @@ export const createCommitBlankConfig = (params: { repo?: string, event: 'api_tri
|
|||||||
|
|
||||||
export const createDevConfig = (params: { repo?: string, event?: string, branch?: string }) => {
|
export const createDevConfig = (params: { repo?: string, event?: string, branch?: string }) => {
|
||||||
const event = params?.event || 'api_trigger_event';
|
const event = params?.event || 'api_trigger_event';
|
||||||
const branch = params?.branch || 'main';
|
const branch = params?.branch || '$';
|
||||||
return `##### 配置开始,保留注释 #####
|
return `##### 配置开始,保留注释 #####
|
||||||
.common_env: &common_env
|
.common_env: &common_env
|
||||||
env:
|
env:
|
||||||
# 使用环境变量管理密钥,推荐使用密钥仓库管理密钥, 详情见 readme.md
|
|
||||||
# 使用仓库密钥时,注释
|
|
||||||
## 可选 API-Key 配置(按需取消注释)
|
|
||||||
# MINIMAX_API_KEY: '' # Minimax 模型
|
|
||||||
# ZHIPU_API_KEY: '' # 智谱 AI
|
|
||||||
# BAILIAN_CODE_API_KEY: '' # 阿里云百炼
|
|
||||||
# VOLCENGINE_API_KEY: '' # 火山引擎
|
|
||||||
# CNB_API_KEY: '' # CNB API
|
|
||||||
# CNB_COOKIE: '' # 可选配置,用cnb.cool的cookie
|
|
||||||
|
|
||||||
# 可选应用配置
|
|
||||||
# FEISHU_APP_ID: '' # 飞书应用 ID
|
|
||||||
# FEISHU_APP_SECRET: '' # 飞书应用密钥
|
|
||||||
|
|
||||||
USERNAME: root
|
USERNAME: root
|
||||||
ASSISTANT_CONFIG_DIR: /workspace/kevisual # ASSISTANT_CONFIG_DIR 环境变量指定了配置文件所在的目录
|
|
||||||
# CNB_KEVISUAL_ORG: kevisual # 私密仓库使用环境配置(默认即可,默认为当前用户组CNB_GROUP_SLUG)
|
|
||||||
# CNB_KEVISUAL_APP: assistant-app # 可选配置(默认即可)
|
|
||||||
# CNB_OPENCLAW: openclaw # 仓库名(默认即可)
|
|
||||||
# CNB_OPENWEBUI: open-webui # 仓库名(默认即可)
|
|
||||||
imports:
|
imports:
|
||||||
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
|
- https://cnb.cool/\${CNB_GROUP_SLUG}/env/-/blob/main/.env
|
||||||
|
# - https://cnb.cool/\${CNB_GROUP_SLUG}/env/-/blob/main/ssh.yml
|
||||||
|
# - https://cnb.cool/\${CNB_GROUP_SLUG}/env/-/blob/main/ssh-config.yml
|
||||||
|
|
||||||
##### 配置结束 #####
|
##### 配置结束 #####
|
||||||
|
|
||||||
@@ -89,21 +72,29 @@ ${branch}:
|
|||||||
services:
|
services:
|
||||||
- vscode
|
- vscode
|
||||||
- docker
|
- docker
|
||||||
runner:
|
|
||||||
cpus: 16
|
|
||||||
#tags: cnb:arch:amd64:gpu
|
|
||||||
imports: !reference [.common_env, imports]
|
imports: !reference [.common_env, imports]
|
||||||
env: !reference [.common_env, env]
|
env: !reference [.common_env, env]
|
||||||
|
runner:
|
||||||
|
cpus: $RUN_CPU
|
||||||
|
#tags: cnb:arch:amd64:gpu
|
||||||
stages:
|
stages:
|
||||||
- name: 环境变量
|
- name: 安装dev-cnb的仓库代码模块
|
||||||
script: printenv > ~/.env.development
|
script: |
|
||||||
|
cd /workspace && find . -mindepth 1 -delete
|
||||||
|
git init
|
||||||
|
git remote add origin https://cnb.cool/kevisual/dev-cnb
|
||||||
|
git fetch origin main
|
||||||
|
git reset --hard origin/main
|
||||||
- name: 启动nginx
|
- name: 启动nginx
|
||||||
script: nginx
|
script: nginx
|
||||||
|
- name: 启动搜索服务
|
||||||
|
script: zsh -i -c 'bun src/cli.ts init start-meilisearch'
|
||||||
- name: 初始化开发机
|
- name: 初始化开发机
|
||||||
script: zsh /workspace/scripts/init.sh
|
script: zsh -i -c 'bun run start'
|
||||||
|
- name: 启动当前工作区
|
||||||
|
script: zsh -i -c 'cloud cnb keep-alive-current-workspace'
|
||||||
# endStages:
|
# endStages:
|
||||||
# - name: 结束阶段
|
# - name: 结束阶段
|
||||||
# script: bun /workspace/scripts/end.ts
|
# script: zsh -i -c 'bun run end'
|
||||||
|
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { query } from '@/modules/query';
|
import { query } from '@/modules/query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cnb } from '@/agents/app'
|
import { queryApi as cnbApi } from '@/modules/cnb-api'
|
||||||
import { WorkspaceInfo } from '@kevisual/cnb'
|
import { WorkspaceInfo } from '@kevisual/cnb'
|
||||||
import { createBuildConfig, createCommitBlankConfig, createDevConfig } from './build';
|
import { createCommitBlankConfig, createDevConfig } from './build';
|
||||||
import { useLayoutStore } from '@/pages/auth/store';
|
import { useLayoutStore } from '@/pages/auth/store';
|
||||||
import { useConfigStore } from '@/pages/config/store';
|
import { Query } from '@kevisual/query';
|
||||||
interface DisplayModule {
|
interface DisplayModule {
|
||||||
activity: boolean;
|
activity: boolean;
|
||||||
contributors: boolean;
|
contributors: boolean;
|
||||||
@@ -52,7 +52,7 @@ interface Data {
|
|||||||
pinned_time: string;
|
pinned_time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceTabType = 'dev' | 'work'
|
type WorkspaceTabType = 'dev' | 'work' | 'link'
|
||||||
|
|
||||||
type BuildConfig = {
|
type BuildConfig = {
|
||||||
repo: string;
|
repo: string;
|
||||||
@@ -77,7 +77,7 @@ type State = {
|
|||||||
showCreateDialog: boolean;
|
showCreateDialog: boolean;
|
||||||
setShowCreateDialog: (show: boolean) => void;
|
setShowCreateDialog: (show: boolean) => void;
|
||||||
getList: (params?: { search?: string }, silent?: boolean) => Promise<any>;
|
getList: (params?: { search?: string }, silent?: boolean) => Promise<any>;
|
||||||
updateRepoInfo: (data: Partial<Data>) => Promise<any>;
|
updateRepoInfo: (data: Partial<Data & { topics: string[] }>) => Promise<any>;
|
||||||
createRepo: (data: { visibility: any, path: string, description: string, license: string }) => Promise<any>;
|
createRepo: (data: { visibility: any, path: string, description: string, license: string }) => Promise<any>;
|
||||||
deleteItem: (repo: string) => Promise<any>;
|
deleteItem: (repo: string) => Promise<any>;
|
||||||
workspaceList: WorkspaceInfo[];
|
workspaceList: WorkspaceInfo[];
|
||||||
@@ -94,7 +94,6 @@ type State = {
|
|||||||
setSyncDialogOpen: (open: boolean) => void;
|
setSyncDialogOpen: (open: boolean) => void;
|
||||||
selectedSyncRepo: Data | null;
|
selectedSyncRepo: Data | null;
|
||||||
setSelectedSyncRepo: (repo: Data | null) => void;
|
setSelectedSyncRepo: (repo: Data | null) => void;
|
||||||
buildSync: (data: Partial<Data>, params: { toRepo?: string, fromRepo?: string }) => Promise<any>;
|
|
||||||
buildUpdate: (data: Partial<Data>, params?: any) => Promise<any>;
|
buildUpdate: (data: Partial<Data>, params?: any) => Promise<any>;
|
||||||
getItem: (repo: string) => Promise<any>;
|
getItem: (repo: string) => Promise<any>;
|
||||||
buildConfig: BuildConfig | null;
|
buildConfig: BuildConfig | null;
|
||||||
@@ -102,6 +101,8 @@ type State = {
|
|||||||
deleteBuildConfig: (params: { repo: Data, user?: any }) => Promise<any>;
|
deleteBuildConfig: (params: { repo: Data, user?: any }) => Promise<any>;
|
||||||
initBuildConfig: (params: { repo: Data, user?: any }) => Promise<any>;
|
initBuildConfig: (params: { repo: Data, user?: any }) => Promise<any>;
|
||||||
buildWorkspace: () => Promise<any>;
|
buildWorkspace: () => Promise<any>;
|
||||||
|
workspaceSecretLink: { title: string, key: string, value?: string }[];
|
||||||
|
getWorkspaceSecretLink: (workspace: WorkspaceInfo) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useRepoStore = create<State>((set, get) => {
|
export const useRepoStore = create<State>((set, get) => {
|
||||||
@@ -236,9 +237,10 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
toast.error('请先保存构建配置');
|
toast.error('请先保存构建配置');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await cnb.build.startBuild(config.repo, {
|
const res = await cnbApi.cnb['cloud-build']({
|
||||||
|
repo: config.repo,
|
||||||
branch: config.branch,
|
branch: config.branch,
|
||||||
env: {},
|
env: {} as any,
|
||||||
event: config.event,
|
event: config.event,
|
||||||
config: config.config,
|
config: config.config,
|
||||||
})
|
})
|
||||||
@@ -248,11 +250,55 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
toast.error(res.message || '构建触发失败')
|
toast.error(res.message || '构建触发失败')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
workspaceSecretLink: [],
|
||||||
|
getWorkspaceSecretLink: async (workspace) => {
|
||||||
|
console.log('获取工作区链接', workspace)
|
||||||
|
const business_id = workspace?.business_id;
|
||||||
|
const baseURL = `https://${business_id}-51515.cnb.run/client/router`;
|
||||||
|
console.log('工作区链接', baseURL)
|
||||||
|
const url = new URL(baseURL);
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
// const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZC1rZXktMSJ9.eyJzdWIiOiJ1c2VyOjBlNzAwZGM4LTkwZGQtNDFiNy05MWRkLTMzNmVhNTFkZTNkMiIsIm5hbWUiOiJyb290IiwiZXhwIjoxNzczMTI4NTMwLCJpc3MiOiJodHRwczovL2NvbnZleC5rZXZpc3VhbC5jbiIsImlhdCI6MTc3MzEyMTMzMCwiYXVkIjoiY29udmV4LWFwcCJ9.g4kANiPc352QFBfa0yb4gl98mLHTruL_3HvIaKYwN1Qy3-P8QV6X_WhqgMOskQphNGsBFC-LRmZq2808GnqwpjDTE0ekXbsO4L9C-D6F3mBMwowqpvmURCRVg6Ys6LSkzw4sM75VbHpfFX3ZQVtZymvAWhxxxvjhdKGPdrdw5bNymTbCw-Y9NrYW6u2mExLrvrfXl3vJqaCz7obj_mR-G_2PB3g5KPQYhWCl8--TkYOS9fiNIYlcacnO36bZXhHheHFZEr_gb8UG5ECg0ND8hsH8TijiYBAY6T93nhGrZG7E0oQY3xXsVm-mkvXP2tLCXwKH7SFmH4M0tdZLRqLqKw'
|
||||||
|
url.searchParams.set('path', 'cnb_board');
|
||||||
|
url.searchParams.set('key', 'live');
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
}
|
||||||
|
}).then(res => res.json());
|
||||||
|
const labelData: { title: string, key: string, value?: string }[] = [
|
||||||
|
{
|
||||||
|
title: 'Opencode Secret',
|
||||||
|
key: 'opencodeUrlSecret',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Openclaw Secret',
|
||||||
|
key: 'openclawUrlSecret',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'StartTime',
|
||||||
|
key: 'buildStartTime',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
if (res.code === 200) {
|
||||||
|
const list = res.data?.list || [];
|
||||||
|
const workspaceSecretLink: { title: string, key: string, value?: string }[] = [];
|
||||||
|
labelData.forEach(item => {
|
||||||
|
const find = list.find((l: any) => l.key === item.key);
|
||||||
|
if (find) {
|
||||||
|
workspaceSecretLink.push({ ...item, value: find.value });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
set({ workspaceSecretLink })
|
||||||
|
}
|
||||||
|
},
|
||||||
getItem: async (repo: string) => {
|
getItem: async (repo: string) => {
|
||||||
const { setLoading } = get();
|
const { setLoading } = get();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await cnb.repo.getRepo(repo)
|
const res = await cnbApi.cnb['get-repo']({ name: repo })
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
const data = res.data!;
|
const data = res.data!;
|
||||||
set({ editRepo: data })
|
set({ editRepo: data })
|
||||||
@@ -275,9 +321,9 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
search: params.search
|
search: params.search
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const res = await cnb.repo.getRepoList(opts)
|
const res = await cnbApi.cnb['list-repos'](opts)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
const list = res.data! || []
|
const list = res.data?.list || []
|
||||||
set({ list });
|
set({ list });
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || '请求失败');
|
toast.error(res.message || '请求失败');
|
||||||
@@ -291,7 +337,7 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
},
|
},
|
||||||
updateRepoInfo: async (data) => {
|
updateRepoInfo: async (data) => {
|
||||||
const repo = data.path!;
|
const repo = data.path!;
|
||||||
let topics = data.topics?.split?.(',');
|
let topics = data.topics as string[];
|
||||||
if (Array.isArray(topics)) {
|
if (Array.isArray(topics)) {
|
||||||
topics = topics.map(t => t.trim()).filter(Boolean);
|
topics = topics.map(t => t.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -299,12 +345,12 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
topics.push('cnb-center')
|
topics.push('cnb-center')
|
||||||
}
|
}
|
||||||
const updateData = {
|
const updateData = {
|
||||||
description: data.description,
|
description: data.description!,
|
||||||
license: data?.license as any,
|
license: data?.license as any,
|
||||||
site: data.site,
|
site: data.site,
|
||||||
topics: topics
|
topics: topics
|
||||||
}
|
}
|
||||||
const res = await cnb.repo.updateRepoInfo(repo, updateData)
|
const res = await cnbApi.cnb['update-repo-info']({ name: repo, ...updateData })
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
toast.success('更新成功');
|
toast.success('更新成功');
|
||||||
} else {
|
} else {
|
||||||
@@ -329,7 +375,7 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
license: data?.license as any,
|
license: data?.license as any,
|
||||||
};
|
};
|
||||||
const res = await cnb.repo.createRepo(createData);
|
const res = await cnbApi.cnb['create-repo'](createData);
|
||||||
console.log('res', res)
|
console.log('res', res)
|
||||||
// if (res.code === 200) {
|
// if (res.code === 200) {
|
||||||
// toast.success('仓库创建成功');
|
// toast.success('仓库创建成功');
|
||||||
@@ -345,7 +391,7 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
},
|
},
|
||||||
deleteItem: async (repo: string) => {
|
deleteItem: async (repo: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await cnb.repo.deleteRepoCookie(repo)
|
const res = await cnbApi.cnb['delete-repo']({ name: repo });
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
toast.success('删除成功');
|
toast.success('删除成功');
|
||||||
// 刷新列表
|
// 刷新列表
|
||||||
@@ -367,7 +413,8 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
},
|
},
|
||||||
workspaceList: [],
|
workspaceList: [],
|
||||||
getWorkspaceList: async () => {
|
getWorkspaceList: async () => {
|
||||||
const res = await cnb.workspace.list({
|
// const res = await cnb.workspace.list({
|
||||||
|
const res = await cnbApi.cnb['list-workspace']({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
pageSize: 100
|
pageSize: 100
|
||||||
})
|
})
|
||||||
@@ -381,9 +428,7 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
startWorkspace: async (data, params = { open: true, branch: 'main' }) => {
|
startWorkspace: async (data, params = { open: true, branch: 'main' }) => {
|
||||||
const repo = data.path;
|
const repo = data.path;
|
||||||
const checkOpen = async () => {
|
const checkOpen = async () => {
|
||||||
const res = await cnb.workspace.startWorkspace(repo!, {
|
const res = await cnbApi.cnb['start-workspace']({ repo: repo!, branch: params.branch || 'main' });
|
||||||
branch: params.branch || 'main'
|
|
||||||
})
|
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
if (!res?.data?.sn) {
|
if (!res?.data?.sn) {
|
||||||
const url = res.data?.url! || '';
|
const url = res.data?.url! || '';
|
||||||
@@ -456,7 +501,7 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
toast.error('未选择工作区');
|
toast.error('未选择工作区');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await cnb.workspace.stopWorkspace({ sn });
|
const res = await cnbApi.cnb['stop-workspace']({ sn })
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (res?.code === 200) {
|
if (res?.code === 200) {
|
||||||
toast.success('工作区已停止');
|
toast.success('工作区已停止');
|
||||||
@@ -469,7 +514,7 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
},
|
},
|
||||||
selectWorkspace: undefined,
|
selectWorkspace: undefined,
|
||||||
getWorkspaceDetail: async (workspaceInfo) => {
|
getWorkspaceDetail: async (workspaceInfo) => {
|
||||||
const res = await cnb.workspace.getDetail(workspaceInfo.slug, workspaceInfo.sn) as any;
|
const res = await cnbApi.cnb['get-workspace']({ repo: workspaceInfo.slug, sn: workspaceInfo.sn }) as any;
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
set({
|
set({
|
||||||
workspaceLink: res.data,
|
workspaceLink: res.data,
|
||||||
@@ -479,31 +524,11 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
workspaceLink: {},
|
workspaceLink: {},
|
||||||
buildSync: async (data, params) => {
|
|
||||||
const repo = data.path!;
|
|
||||||
const toRepo = params.toRepo;
|
|
||||||
const fromRepo = params.fromRepo;
|
|
||||||
if (!toRepo && !fromRepo) {
|
|
||||||
toast.error('请选择同步的目标仓库或来源仓库')
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let event = toRepo ? 'api_trigger_sync_to_gitea' : 'api_trigger_sync_from_gitea';
|
|
||||||
const res = await cnb.build.startBuild(repo, {
|
|
||||||
branch: 'main',
|
|
||||||
env: {},
|
|
||||||
event: event,
|
|
||||||
config: createBuildConfig({ repo: toRepo! || fromRepo! }),
|
|
||||||
})
|
|
||||||
if (res.code === 200) {
|
|
||||||
toast.success('同步提交成功')
|
|
||||||
} else {
|
|
||||||
toast.error(res.message || '同步提交失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
buildUpdate: async (data) => {
|
buildUpdate: async (data) => {
|
||||||
const res = await cnb.build.startBuild(data.path!, {
|
const res = await cnbApi.cnb['cloud-build']({
|
||||||
|
repo: data.path!,
|
||||||
branch: 'main',
|
branch: 'main',
|
||||||
env: {},
|
env: {} as any,
|
||||||
event: 'api_trigger_event',
|
event: 'api_trigger_event',
|
||||||
config: createCommitBlankConfig({ repo: data.path!, event: 'api_trigger_event' }),
|
config: createCommitBlankConfig({ repo: data.path!, event: 'api_trigger_event' }),
|
||||||
})
|
})
|
||||||
@@ -521,16 +546,22 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type WorkspaceOpen = {
|
export type WorkspaceOpen = {
|
||||||
codebuddy: string;
|
url?: string
|
||||||
codebuddycn: string;
|
webide?: string
|
||||||
cursor: string;
|
jumpUrl?: string
|
||||||
jetbrains: Record<string, string>;
|
remoteSsh?: string
|
||||||
jumpUrl: string;
|
jetbrains?: Record<string, string>
|
||||||
remoteSsh: string;
|
codebuddy?: string
|
||||||
ssh: string;
|
codebuddycn?: string
|
||||||
vscode: string;
|
vscode?: string
|
||||||
'vscode-insiders': string;
|
cursor?: string
|
||||||
webide: string;
|
'vscode-insiders'?: string
|
||||||
|
trae?: string
|
||||||
|
'trae-cn'?: string
|
||||||
|
windsurf?: string
|
||||||
|
'windsurf-next'?: string
|
||||||
|
antigravity?: string
|
||||||
|
ssh?: string
|
||||||
}
|
}
|
||||||
const openWorkspace = (workspace: WorkspaceInfo, params: { vscode?: boolean, ssh?: boolean }) => {
|
const openWorkspace = (workspace: WorkspaceInfo, params: { vscode?: boolean, ssh?: boolean }) => {
|
||||||
const openVsCode = params?.vscode ?? true;
|
const openVsCode = params?.vscode ?? true;
|
||||||
|
|||||||
11
src/pages/sidebar/components/CNBBlackLogo.tsx
Normal file
11
src/pages/sidebar/components/CNBBlackLogo.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export const Logo = (props: React.SVGProps<SVGSVGElement>) => {
|
||||||
|
return (
|
||||||
|
<svg {...props} width="320" height="320" viewBox="0 0 320 320" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M228.906 40.2412C229.882 37.5108 228.906 34.3903 226.759 32.44C219.342 26.004 200.799 12.3519 173.082 10.4016C141.852 8.06121 122.528 16.4475 112.769 22.6885C108.474 25.4189 108.279 31.4649 112.183 34.3903L191.625 96.2149C198.652 101.676 208.997 98.5553 211.729 90.169L228.711 40.2412H228.906Z" fill="black" />
|
||||||
|
<path d="M32.9381 223.564C29.6199 225.71 28.2536 229.805 29.2295 233.511C32.1573 244.432 41.3312 266.861 66.9009 287.534C92.4706 308.012 122.725 310.353 135.607 309.963C139.511 309.963 142.829 307.427 144 303.722L194.945 142.627C198.653 130.925 185.576 121.173 175.426 127.999L32.9381 223.564Z" fill="black" />
|
||||||
|
<path d="M70.2169 53.4955C67.6794 52.5203 64.9468 52.7153 62.6045 53.8855C53.2355 58.9563 29.032 74.7538 16.54 107.324C6.78054 132.288 10.0987 159.982 12.8314 173.439C13.6121 177.925 18.2967 180.46 22.5908 178.705L175.424 119.026C186.354 114.735 186.354 99.3276 175.424 95.0369L70.2169 53.4955Z" fill="black" />
|
||||||
|
<path d="M297.03 168.968C301.519 171.893 307.57 169.358 308.351 164.092C310.303 150.05 312.06 125.866 304.057 107.338C293.321 82.9591 274.974 67.7468 266.19 61.7008C263.458 59.7505 259.749 59.9456 257.212 62.2859L218.564 96.4162C212.318 102.072 212.904 112.019 219.931 116.699L297.03 168.968Z" fill="black" />
|
||||||
|
<path d="M189.089 299.428C188.699 303.914 192.603 307.814 197.092 307.229C211.731 305.669 241.79 299.818 264.237 278.365C286.098 257.496 293.32 232.728 295.272 222.781C295.858 220.051 295.272 217.32 293.515 215.175L225.98 131.897C218.758 122.925 204.119 127.411 203.143 138.918L189.089 299.233V299.428Z" fill="black" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/pages/sidebar/components/Sidebar.tsx
Normal file
40
src/pages/sidebar/components/Sidebar.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { FolderKanban, LayoutDashboard, Settings, PlayCircle, Cloud } from 'lucide-react'
|
||||||
|
import { Sidebar, type NavItem } from '@/components/a/Sidebar'
|
||||||
|
import { Logo } from './CNBBlackLogo.tsx'
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: '仓库管理',
|
||||||
|
path: '/',
|
||||||
|
icon: <FolderKanban className="w-5 h-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '云端环境',
|
||||||
|
path: '/cloud-env',
|
||||||
|
icon: <Cloud className="w-5 h-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '工作空间',
|
||||||
|
path: '/workspaces',
|
||||||
|
icon: <LayoutDashboard className="w-5 h-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '应用配置',
|
||||||
|
path: '/config',
|
||||||
|
icon: <Settings className="w-5 h-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '其他',
|
||||||
|
path: '/demo',
|
||||||
|
icon: <PlayCircle className="w-5 h-5" />,
|
||||||
|
isDeveloping: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SidebarLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Sidebar items={navItems} title='云原生' logo={<Logo className='w-6 h-6' />}>
|
||||||
|
{children}
|
||||||
|
</Sidebar>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/pages/sidebar/components/index.ts
Normal file
1
src/pages/sidebar/components/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { SidebarLayout } from './Sidebar'
|
||||||
0
src/pages/sidebar/page.tsx
Normal file
0
src/pages/sidebar/page.tsx
Normal file
@@ -1,130 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import { query } from '@/modules/query';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { cnb } from '@/agents/app'
|
|
||||||
|
|
||||||
interface DisplayModule {
|
|
||||||
activity: boolean;
|
|
||||||
contributors: boolean;
|
|
||||||
release: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Languages {
|
|
||||||
language: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Data {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
freeze: boolean;
|
|
||||||
status: number;
|
|
||||||
visibility_level: string;
|
|
||||||
flags: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
description: string;
|
|
||||||
site: string;
|
|
||||||
topics: string;
|
|
||||||
license: string;
|
|
||||||
display_module: DisplayModule;
|
|
||||||
star_count: number;
|
|
||||||
fork_count: number;
|
|
||||||
mark_count: number;
|
|
||||||
last_updated_at?: string | null;
|
|
||||||
web_url: string;
|
|
||||||
path: string;
|
|
||||||
tags: any;
|
|
||||||
open_issue_count: number;
|
|
||||||
open_pull_request_count: number;
|
|
||||||
languages: Languages;
|
|
||||||
second_languages: Languages;
|
|
||||||
last_update_username: string;
|
|
||||||
last_update_nickname: string;
|
|
||||||
access: string;
|
|
||||||
stared: boolean;
|
|
||||||
star_time: string;
|
|
||||||
pinned: boolean;
|
|
||||||
pinned_time: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
formData: Record<string, any>;
|
|
||||||
setFormData: (data: Record<string, any>) => void;
|
|
||||||
showEdit: boolean;
|
|
||||||
setShowEdit: (showEdit: boolean) => void;
|
|
||||||
loading: boolean;
|
|
||||||
setLoading: (loading: boolean) => void;
|
|
||||||
list: Data[];
|
|
||||||
editRepo: Data | null;
|
|
||||||
setEditRepo: (repo: Data | null) => void;
|
|
||||||
showEditDialog: boolean;
|
|
||||||
setShowEditDialog: (show: boolean) => void;
|
|
||||||
getList: () => Promise<any>;
|
|
||||||
updateRepoInfo: (data: Partial<Data>) => Promise<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useRepoStore = create<State>((set, get) => {
|
|
||||||
return {
|
|
||||||
formData: {},
|
|
||||||
setFormData: (data) => set({ formData: data }),
|
|
||||||
showEdit: false,
|
|
||||||
setShowEdit: (showEdit) => set({ showEdit }),
|
|
||||||
loading: false,
|
|
||||||
setLoading: (loading) => set({ loading }),
|
|
||||||
list: [],
|
|
||||||
editRepo: null,
|
|
||||||
setEditRepo: (repo) => set({ editRepo: repo }),
|
|
||||||
showEditDialog: false,
|
|
||||||
setShowEditDialog: (show) => set({ showEditDialog: show }),
|
|
||||||
getItem: async (id) => {
|
|
||||||
const { setLoading } = get();
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await query.post({
|
|
||||||
path: 'demo',
|
|
||||||
key: 'item',
|
|
||||||
data: { id }
|
|
||||||
})
|
|
||||||
if (res.code === 200) {
|
|
||||||
return res;
|
|
||||||
} else {
|
|
||||||
toast.error(res.message || '请求失败');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getList: async () => {
|
|
||||||
const { setLoading } = get();
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await cnb.repo.getRepoList({})
|
|
||||||
if (res.code === 200) {
|
|
||||||
const list = res.data! || []
|
|
||||||
set({ list });
|
|
||||||
} else {
|
|
||||||
toast.error(res.message || '请求失败');
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateRepoInfo: async (data) => {
|
|
||||||
const repo = data.path!;
|
|
||||||
const updateData = {
|
|
||||||
description: data.description,
|
|
||||||
license: data?.license as any,
|
|
||||||
site: data.site,
|
|
||||||
topics: data.topics?.split?.(','),
|
|
||||||
}
|
|
||||||
const res = await cnb.repo.updateRepoInfo(repo, updateData)
|
|
||||||
if (res.code === 200) {
|
|
||||||
toast.success('更新成功');
|
|
||||||
} else {
|
|
||||||
toast.error(res.message || '更新失败');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
105
src/pages/workspaces/components/CreateDialog.tsx
Normal file
105
src/pages/workspaces/components/CreateDialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { TagInput } from "./TagInput";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
export function CreateDialog({ open, onClose, onSubmit }: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: { title: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [link, setLink] = useState('');
|
||||||
|
const [summary, setSummary] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
alert('请输入标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
title: title.trim(),
|
||||||
|
tags,
|
||||||
|
link: link.trim(),
|
||||||
|
summary: summary.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-lg!">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>创建Workspace</DialogTitle>
|
||||||
|
<DialogDescription>填写以下信息创建一个新的Workspace</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-title">标题</Label>
|
||||||
|
<Input
|
||||||
|
id="create-title"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
placeholder="请输入标题"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>标签</Label>
|
||||||
|
<TagInput value={tags} onChange={setTags} placeholder="输入标签后按回车添加" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-link">链接</Label>
|
||||||
|
<Input
|
||||||
|
id="create-link"
|
||||||
|
value={link}
|
||||||
|
onChange={e => setLink(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-summary">摘要</Label>
|
||||||
|
<Input
|
||||||
|
id="create-summary"
|
||||||
|
value={summary}
|
||||||
|
onChange={e => setSummary(e.target.value)}
|
||||||
|
placeholder="简要描述"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-description">描述</Label>
|
||||||
|
<Textarea
|
||||||
|
id="create-description"
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
placeholder="详细描述..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={submitting}>取消</Button>
|
||||||
|
<Button variant="outline" onClick={handleSubmit} disabled={submitting}>
|
||||||
|
{submitting ? '创建中...' : '创建'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/pages/workspaces/components/EditDialog.tsx
Normal file
117
src/pages/workspaces/components/EditDialog.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { TagInput } from "./TagInput";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
export function EditDialog({ open, item, onClose, onSubmit }: {
|
||||||
|
open: boolean;
|
||||||
|
item: any;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (id: string, data: { title?: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [link, setLink] = useState('');
|
||||||
|
const [summary, setSummary] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && item) {
|
||||||
|
setTitle(item.title || '');
|
||||||
|
setTags(item.tags || []);
|
||||||
|
setLink(item.link || '');
|
||||||
|
setSummary(item.summary || '');
|
||||||
|
setDescription(item.description || '');
|
||||||
|
}
|
||||||
|
}, [open, item]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
alert('请输入标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!item?.id) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(item.id, {
|
||||||
|
title: title.trim(),
|
||||||
|
tags,
|
||||||
|
link: link.trim(),
|
||||||
|
summary: summary.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-lg!">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑Workspace</DialogTitle>
|
||||||
|
<DialogDescription>修改Workspace信息</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-title">标题</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-title"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
placeholder="请输入标题"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>标签</Label>
|
||||||
|
<TagInput value={tags} onChange={setTags} placeholder="输入标签后按回车添加" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-link">链接</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-link"
|
||||||
|
value={link}
|
||||||
|
onChange={e => setLink(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-summary">摘要</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-summary"
|
||||||
|
value={summary}
|
||||||
|
onChange={e => setSummary(e.target.value)}
|
||||||
|
placeholder="简要描述"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-description">描述</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-description"
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
placeholder="详细描述..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={submitting}>取消</Button>
|
||||||
|
<Button variant="outline" onClick={handleSubmit} disabled={submitting}>
|
||||||
|
{submitting ? '保存中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/pages/workspaces/components/TagInput.tsx
Normal file
53
src/pages/workspaces/components/TagInput.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export function TagInput({ value, onChange, placeholder }: {
|
||||||
|
value: string[];
|
||||||
|
onChange: (tags: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && input.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!value.includes(input.trim())) {
|
||||||
|
onChange([...value, input.trim()]);
|
||||||
|
}
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (tag: string) => {
|
||||||
|
onChange(value.filter(t => t !== tag));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder || '输入标签后按回车添加'}
|
||||||
|
/>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{value.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline" className="gap-1">
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(tag)}
|
||||||
|
className="hover:text-destructive"
|
||||||
|
>
|
||||||
|
<XIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/pages/workspaces/components/index.ts
Normal file
3
src/pages/workspaces/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { CreateDialog } from "./CreateDialog";
|
||||||
|
export { EditDialog } from "./EditDialog";
|
||||||
|
export { TagInput } from "./TagInput";
|
||||||
153
src/pages/workspaces/page.tsx
Normal file
153
src/pages/workspaces/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useWorkspaceStore, type WorkspaceState } from "./store";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { CreateDialog, EditDialog } from "./components";
|
||||||
|
import { SearchIcon, RefreshCwIcon, PlusIcon, PencilIcon, TrashIcon } from "lucide-react";
|
||||||
|
import { SidebarLayout } from "@/pages/sidebar/components";
|
||||||
|
|
||||||
|
export const App = () => {
|
||||||
|
const workspaceStore = useWorkspaceStore(useShallow((state: WorkspaceState) => {
|
||||||
|
return {
|
||||||
|
list: state.list,
|
||||||
|
loading: state.loading,
|
||||||
|
getList: state.getList,
|
||||||
|
createItem: state.createItem,
|
||||||
|
updateItem: state.updateItem,
|
||||||
|
deleteItem: state.deleteItem,
|
||||||
|
showCreateDialog: state.showCreateDialog,
|
||||||
|
setShowCreateDialog: state.setShowCreateDialog,
|
||||||
|
showEditDialog: state.showEditDialog,
|
||||||
|
setShowEditDialog: state.setShowEditDialog,
|
||||||
|
editingItem: state.editingItem,
|
||||||
|
setEditingItem: state.setEditingItem,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
workspaceStore.getList({ search });
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (confirm('确定要删除这个workspace吗?')) {
|
||||||
|
await workspaceStore.deleteItem(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
workspaceStore.getList({ search });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (item: any) => {
|
||||||
|
workspaceStore.setEditingItem(item);
|
||||||
|
workspaceStore.setShowEditDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
workspaceStore.setShowCreateDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarLayout>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex justify-between items-center mb-5">
|
||||||
|
<h1 className="text-2xl font-semibold">Workspaces</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索workspace..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-8 w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||||
|
<RefreshCwIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCreate}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
创建Workspace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{workspaceStore.loading ? (
|
||||||
|
<div className="text-center py-10 text-muted-foreground">加载中...</div>
|
||||||
|
) : workspaceStore.list.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-muted-foreground">暂无workspace数据</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{workspaceStore.list.map((item) => (
|
||||||
|
<Card key={item.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{item.title || '未命名'}</CardTitle>
|
||||||
|
<CardDescription className="text-xs">ID: {item.id}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{item.tags && item.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.tags.map((tag, index) => (
|
||||||
|
<Badge key={index} variant="outline">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.summary && (
|
||||||
|
<p className="text-sm text-muted-foreground">{item.summary}</p>
|
||||||
|
)}
|
||||||
|
{item.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2">{item.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
创建时间: {item.createdAt ? dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
更新时间: {item.updatedAt ? dayjs(item.updatedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleEdit(item)}>
|
||||||
|
<PencilIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleDelete(item.id)}>
|
||||||
|
<TrashIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateDialog
|
||||||
|
open={workspaceStore.showCreateDialog}
|
||||||
|
onClose={() => workspaceStore.setShowCreateDialog(false)}
|
||||||
|
onSubmit={workspaceStore.createItem}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditDialog
|
||||||
|
open={workspaceStore.showEditDialog}
|
||||||
|
item={workspaceStore.editingItem}
|
||||||
|
onClose={() => {
|
||||||
|
workspaceStore.setShowEditDialog(false);
|
||||||
|
workspaceStore.setEditingItem(null);
|
||||||
|
}}
|
||||||
|
onSubmit={workspaceStore.updateItem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SidebarLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
156
src/pages/workspaces/store/index.ts
Normal file
156
src/pages/workspaces/store/index.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useMarkStore } from '@kevisual/api/store-mark';
|
||||||
|
export { useMarkStore }
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { queryApi } from '@/modules/mark-api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
type WorkspaceItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
tags?: string[];
|
||||||
|
link?: string;
|
||||||
|
summary?: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceState = {
|
||||||
|
edit: boolean;
|
||||||
|
setEdit: (edit: boolean) => void;
|
||||||
|
list: WorkspaceItem[];
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
// 弹窗状态
|
||||||
|
showCreateDialog: boolean;
|
||||||
|
setShowCreateDialog: (show: boolean) => void;
|
||||||
|
showEditDialog: boolean;
|
||||||
|
setShowEditDialog: (show: boolean) => void;
|
||||||
|
editingItem: WorkspaceItem | null;
|
||||||
|
setEditingItem: (item: WorkspaceItem | null) => void;
|
||||||
|
// 数据操作
|
||||||
|
getList: (params?: { search?: string, page?: number, pageSize?: number }) => Promise<void>;
|
||||||
|
createItem: (data: { title: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
|
||||||
|
updateItem: (id: string, data: { title?: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
|
||||||
|
deleteItem: (id: string) => Promise<void>;
|
||||||
|
getItem: (id: string) => Promise<WorkspaceItem | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { WorkspaceState, WorkspaceItem };
|
||||||
|
|
||||||
|
export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
|
||||||
|
edit: false,
|
||||||
|
setEdit: (edit) => set({ edit }),
|
||||||
|
list: [],
|
||||||
|
loading: false,
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
showCreateDialog: false,
|
||||||
|
setShowCreateDialog: (show) => set({ showCreateDialog: show }),
|
||||||
|
showEditDialog: false,
|
||||||
|
setShowEditDialog: (show) => set({ showEditDialog: show }),
|
||||||
|
editingItem: null,
|
||||||
|
setEditingItem: (item) => set({ editingItem: item }),
|
||||||
|
|
||||||
|
getList: async (params = {}) => {
|
||||||
|
const { page = 1, pageSize = 20, search } = params;
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const res = await queryApi.mark.list({
|
||||||
|
markType: 'cnb',
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search,
|
||||||
|
sort: 'DESC'
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ list: res.data?.list || [] });
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '获取列表失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取workspace列表失败', e);
|
||||||
|
toast.error('获取列表失败');
|
||||||
|
} finally {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createItem: async (data) => {
|
||||||
|
try {
|
||||||
|
const res = await queryApi.mark.create({
|
||||||
|
title: data.title,
|
||||||
|
markType: 'cnb',
|
||||||
|
tags: data.tags || [],
|
||||||
|
link: data.link || '',
|
||||||
|
summary: data.summary || '',
|
||||||
|
description: data.description || ''
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('创建成功');
|
||||||
|
get().getList();
|
||||||
|
set({ showCreateDialog: false });
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '创建失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('创建失败', e);
|
||||||
|
toast.error('创建失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateItem: async (id, data) => {
|
||||||
|
try {
|
||||||
|
const res = await queryApi.mark.update({
|
||||||
|
id,
|
||||||
|
data: {
|
||||||
|
title: data.title || '',
|
||||||
|
tags: data.tags || [],
|
||||||
|
link: data.link || '',
|
||||||
|
summary: data.summary || '',
|
||||||
|
description: data.description || ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('更新成功');
|
||||||
|
get().getList();
|
||||||
|
set({ showEditDialog: false, editingItem: null });
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '更新失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('更新失败', e);
|
||||||
|
toast.error('更新失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteItem: async (id) => {
|
||||||
|
try {
|
||||||
|
const res = await queryApi.mark.delete({ id });
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('删除成功');
|
||||||
|
get().getList();
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('删除失败', e);
|
||||||
|
toast.error('删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getItem: async (id) => {
|
||||||
|
try {
|
||||||
|
const res = await queryApi.mark.get({ id });
|
||||||
|
if (res.code === 200) {
|
||||||
|
return res.data;
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '获取详情失败');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取详情失败', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
@@ -10,9 +10,12 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as DemoRouteImport } from './routes/demo'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as WorkspacesIndexRouteImport } from './routes/workspaces/index'
|
||||||
import { Route as RepoIndexRouteImport } from './routes/repo/index'
|
import { Route as RepoIndexRouteImport } from './routes/repo/index'
|
||||||
import { Route as ConfigIndexRouteImport } from './routes/config/index'
|
import { Route as ConfigIndexRouteImport } from './routes/config/index'
|
||||||
|
import { Route as CloudEnvIndexRouteImport } from './routes/cloud-env/index'
|
||||||
import { Route as ConfigGiteaRouteImport } from './routes/config/gitea'
|
import { Route as ConfigGiteaRouteImport } from './routes/config/gitea'
|
||||||
|
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
@@ -20,11 +23,21 @@ const LoginRoute = LoginRouteImport.update({
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DemoRoute = DemoRouteImport.update({
|
||||||
|
id: '/demo',
|
||||||
|
path: '/demo',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const WorkspacesIndexRoute = WorkspacesIndexRouteImport.update({
|
||||||
|
id: '/workspaces/',
|
||||||
|
path: '/workspaces/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const RepoIndexRoute = RepoIndexRouteImport.update({
|
const RepoIndexRoute = RepoIndexRouteImport.update({
|
||||||
id: '/repo/',
|
id: '/repo/',
|
||||||
path: '/repo/',
|
path: '/repo/',
|
||||||
@@ -35,6 +48,11 @@ const ConfigIndexRoute = ConfigIndexRouteImport.update({
|
|||||||
path: '/config/',
|
path: '/config/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const CloudEnvIndexRoute = CloudEnvIndexRouteImport.update({
|
||||||
|
id: '/cloud-env/',
|
||||||
|
path: '/cloud-env/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ConfigGiteaRoute = ConfigGiteaRouteImport.update({
|
const ConfigGiteaRoute = ConfigGiteaRouteImport.update({
|
||||||
id: '/config/gitea',
|
id: '/config/gitea',
|
||||||
path: '/config/gitea',
|
path: '/config/gitea',
|
||||||
@@ -43,40 +61,77 @@ const ConfigGiteaRoute = ConfigGiteaRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/demo': typeof DemoRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/config/gitea': typeof ConfigGiteaRoute
|
'/config/gitea': typeof ConfigGiteaRoute
|
||||||
|
'/cloud-env/': typeof CloudEnvIndexRoute
|
||||||
'/config/': typeof ConfigIndexRoute
|
'/config/': typeof ConfigIndexRoute
|
||||||
'/repo/': typeof RepoIndexRoute
|
'/repo/': typeof RepoIndexRoute
|
||||||
|
'/workspaces/': typeof WorkspacesIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/demo': typeof DemoRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/config/gitea': typeof ConfigGiteaRoute
|
'/config/gitea': typeof ConfigGiteaRoute
|
||||||
|
'/cloud-env': typeof CloudEnvIndexRoute
|
||||||
'/config': typeof ConfigIndexRoute
|
'/config': typeof ConfigIndexRoute
|
||||||
'/repo': typeof RepoIndexRoute
|
'/repo': typeof RepoIndexRoute
|
||||||
|
'/workspaces': typeof WorkspacesIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/demo': typeof DemoRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/config/gitea': typeof ConfigGiteaRoute
|
'/config/gitea': typeof ConfigGiteaRoute
|
||||||
|
'/cloud-env/': typeof CloudEnvIndexRoute
|
||||||
'/config/': typeof ConfigIndexRoute
|
'/config/': typeof ConfigIndexRoute
|
||||||
'/repo/': typeof RepoIndexRoute
|
'/repo/': typeof RepoIndexRoute
|
||||||
|
'/workspaces/': typeof WorkspacesIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/login' | '/config/gitea' | '/config/' | '/repo/'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/demo'
|
||||||
|
| '/login'
|
||||||
|
| '/config/gitea'
|
||||||
|
| '/cloud-env/'
|
||||||
|
| '/config/'
|
||||||
|
| '/repo/'
|
||||||
|
| '/workspaces/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/login' | '/config/gitea' | '/config' | '/repo'
|
to:
|
||||||
id: '__root__' | '/' | '/login' | '/config/gitea' | '/config/' | '/repo/'
|
| '/'
|
||||||
|
| '/demo'
|
||||||
|
| '/login'
|
||||||
|
| '/config/gitea'
|
||||||
|
| '/cloud-env'
|
||||||
|
| '/config'
|
||||||
|
| '/repo'
|
||||||
|
| '/workspaces'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/demo'
|
||||||
|
| '/login'
|
||||||
|
| '/config/gitea'
|
||||||
|
| '/cloud-env/'
|
||||||
|
| '/config/'
|
||||||
|
| '/repo/'
|
||||||
|
| '/workspaces/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
DemoRoute: typeof DemoRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
ConfigGiteaRoute: typeof ConfigGiteaRoute
|
ConfigGiteaRoute: typeof ConfigGiteaRoute
|
||||||
|
CloudEnvIndexRoute: typeof CloudEnvIndexRoute
|
||||||
ConfigIndexRoute: typeof ConfigIndexRoute
|
ConfigIndexRoute: typeof ConfigIndexRoute
|
||||||
RepoIndexRoute: typeof RepoIndexRoute
|
RepoIndexRoute: typeof RepoIndexRoute
|
||||||
|
WorkspacesIndexRoute: typeof WorkspacesIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -88,6 +143,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/demo': {
|
||||||
|
id: '/demo'
|
||||||
|
path: '/demo'
|
||||||
|
fullPath: '/demo'
|
||||||
|
preLoaderRoute: typeof DemoRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -95,6 +157,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/workspaces/': {
|
||||||
|
id: '/workspaces/'
|
||||||
|
path: '/workspaces'
|
||||||
|
fullPath: '/workspaces/'
|
||||||
|
preLoaderRoute: typeof WorkspacesIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/repo/': {
|
'/repo/': {
|
||||||
id: '/repo/'
|
id: '/repo/'
|
||||||
path: '/repo'
|
path: '/repo'
|
||||||
@@ -109,6 +178,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ConfigIndexRouteImport
|
preLoaderRoute: typeof ConfigIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/cloud-env/': {
|
||||||
|
id: '/cloud-env/'
|
||||||
|
path: '/cloud-env'
|
||||||
|
fullPath: '/cloud-env/'
|
||||||
|
preLoaderRoute: typeof CloudEnvIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/config/gitea': {
|
'/config/gitea': {
|
||||||
id: '/config/gitea'
|
id: '/config/gitea'
|
||||||
path: '/config/gitea'
|
path: '/config/gitea'
|
||||||
@@ -121,10 +197,13 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
DemoRoute: DemoRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
ConfigGiteaRoute: ConfigGiteaRoute,
|
ConfigGiteaRoute: ConfigGiteaRoute,
|
||||||
|
CloudEnvIndexRoute: CloudEnvIndexRoute,
|
||||||
ConfigIndexRoute: ConfigIndexRoute,
|
ConfigIndexRoute: ConfigIndexRoute,
|
||||||
RepoIndexRoute: RepoIndexRoute,
|
RepoIndexRoute: RepoIndexRoute,
|
||||||
|
WorkspacesIndexRoute: WorkspacesIndexRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -4,24 +4,37 @@ import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
|||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { AuthProvider } from '@/pages/auth'
|
import { AuthProvider } from '@/pages/auth'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
import { useLayoutStore } from '@/pages/auth/store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { stackQueryClient } from '@/modules/query'
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import clsx from 'clsx';
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
|
const store = useLayoutStore(useShallow(state => ({
|
||||||
|
showBaseHeader: state.showBaseHeader,
|
||||||
|
})));
|
||||||
return (
|
return (
|
||||||
<div className='h-full overflow-hidden'>
|
<QueryClientProvider client={stackQueryClient}>
|
||||||
<LayoutMain />
|
<div className='h-full overflow-hidden'>
|
||||||
<AuthProvider mustLogin={false}>
|
<LayoutMain />
|
||||||
<TooltipProvider>
|
<AuthProvider mustLogin={false}>
|
||||||
<main className='h-[calc(100%-3rem)] overflow-auto scrollbar'>
|
<TooltipProvider>
|
||||||
<Outlet />
|
<main className={clsx('overflow-auto scrollbar', {
|
||||||
</main>
|
'h-[calc(100%-3rem)]': store.showBaseHeader,
|
||||||
</TooltipProvider>
|
'h-full': !store.showBaseHeader,
|
||||||
</AuthProvider>
|
})}>
|
||||||
<TanStackRouterDevtools position="bottom-right" />
|
<Outlet />
|
||||||
<Toaster />
|
</main>
|
||||||
</div>
|
</TooltipProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
<TanStackRouterDevtools position="bottom-right" />
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
10
src/routes/cloud-env/index.tsx
Normal file
10
src/routes/cloud-env/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import App from '@/pages/cloud-env/page'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/cloud-env/')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
9
src/routes/demo.tsx
Normal file
9
src/routes/demo.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import App from '@/pages/demo/page'
|
||||||
|
export const Route = createFileRoute('/demo')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
9
src/routes/workspaces/index.tsx
Normal file
9
src/routes/workspaces/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import App from '@/pages/workspaces/page'
|
||||||
|
export const Route = createFileRoute('/workspaces/')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@ import path from 'path';
|
|||||||
import pkgs from './package.json';
|
import pkgs from './package.json';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const env = dotenv.config().parsed || {};
|
||||||
|
const isDev = env.NODE_ENV === 'development' || process.env.NODE_ENV === 'development';
|
||||||
const basename = isDev ? '/' : pkgs?.basename || '/';
|
const basename = isDev ? '/' : pkgs?.basename || '/';
|
||||||
|
|
||||||
let target = process.env.VITE_API_URL || 'http://localhost:51515';
|
let target = env.VITE_API_URL || process.env.API_URL || 'http://localhost:51515';
|
||||||
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
|
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
|
||||||
let proxy = {
|
let proxy = {
|
||||||
'/root/': apiProxy,
|
'/root/': apiProxy,
|
||||||
@@ -26,7 +29,10 @@ export default defineConfig({
|
|||||||
autoCodeSplitting: true,
|
autoCodeSplitting: true,
|
||||||
}),
|
}),
|
||||||
react(),
|
react(),
|
||||||
tailwindcss()
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
injectRegister: 'auto',
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user