Compare commits

...

17 Commits

Author SHA1 Message Date
xiongxiao
0b08b82356 init 2026-03-17 20:54:06 +08:00
xiongxiao
3a821b1486 feat: 将仓库可见性改为选择器组件,支持 public/private/protected 2026-03-16 01:22:49 +08:00
xiongxiao
81b52cce8c chore: 更新依赖版本并优化仓库页面移动端布局
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:59:50 +08:00
xiongxiao
500ceb2e42 fix: 优化移动端页面体验
- repos 页面:标题和按钮移动端换行显示,响应式布局优化
- config 页面:移动端无边框,全屏宽度,按钮垂直堆叠
- gitea config 页面:同 config 页面优化
- BuildConfig、RepoCard、Dialog 等组件响应式优化
2026-03-13 05:39:26 +08:00
a40cc2175e fix 2026-03-11 03:09:52 +08:00
xiongxiao
f74d5a4510 feat: 添加工作区链接功能,更新状态管理以支持获取和显示工作区的秘密链接 2026-03-10 14:28:50 +08:00
4ed81a1c68 feat: add cnb API module with various endpoints for workspace management, repository operations, and issue tracking 2026-03-10 01:23:18 +08:00
253ef2ac7d feat: 添加 CNB API 接口定义,包括用户验证、仓库管理和工作空间操作 2026-03-06 02:50:35 +08:00
6c914c3186 fix: 更新 @kevisual/kv-login 依赖版本至 0.1.17,调整 AuthProvider 登录状态 2026-03-06 00:47:14 +08:00
eebdaec389 update 2026-03-05 22:24:45 +08:00
575feec78b update 2026-03-05 22:02:45 +08:00
xiongxiao
d196b24d07 feat: 在 RepoCard 组件中添加站点链接点击事件,防止事件冒泡 2026-03-01 01:34:59 +08:00
xiongxiao
20edf1893e chore: update dependencies and improve configuration handling
- Bump version to 0.0.7 in package.json and update deployment script.
- Add new dependencies in bun.lock for improved functionality.
- Modify loadFromRemote methods in Gitea and general config stores to return boolean for better error handling.
- Enhance BuildConfig component to ensure proper loading state management.
- Simplify RepoCard component by removing duplicate repository access option.
- Update WorkspaceDetailDialog to include workspace link in state management.
- Fix branch default value in createDevConfig function to use a placeholder.
2026-03-01 01:34:12 +08:00
0a0acf1fe7 feat: 添加工作区页面及相关状态管理,优化路由配置 2026-02-28 04:12:10 +08:00
364e903d5f feat: 重构 openInCNB 函数,简化代码逻辑并优化导航处理 2026-02-27 02:29:11 +08:00
f05aab2430 feat: 优化 RepoCard 组件,添加条件渲染和样式调整 2026-02-27 02:24:19 +08:00
58563ece93 feat: 添加远端配置保存与加载功能,优化配置检查逻辑 2026-02-27 01:59:06 +08:00
60 changed files with 5203 additions and 1174 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -25,19 +25,36 @@ src/
``` ```
pages/page-app/ pages/page-app/
├── components/ # 模块专属组件 ├── components/ # 模块专属组件
├── store/ # 模块状态管理 ├── hooks/ # 模块 React Query hooksAPI 查询封装)
── 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 工具函数

746
bun.lock

File diff suppressed because it is too large Load Diff

21
kevisual.json Normal file
View 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": {}
}

View File

@@ -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,35 @@
"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",
"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",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.11" "zustand": "^5.0.12"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
@@ -52,20 +53,21 @@
"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", "@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

File diff suppressed because it is too large Load Diff

43
public/auth.json Normal file
View File

@@ -0,0 +1,43 @@
{
"metadata": {
"name": "kevisual",
"share": "public"
},
"registry": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template",
"clone": {
".": {
"enabled": true
}
},
"syncd": [
{
"files": [
"**/*"
],
"registry": ""
}
],
"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",
"src/agents/app.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/agents/app.ts",
"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/pages/page.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/page.tsx",
"src/routes/__root.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/__root.tsx",
"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"
}
}

View File

@@ -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" />

View File

@@ -1,28 +1,9 @@
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 { useGiteaConfigStore } from '@/pages/config/gitea/store'
import { CNB } from '@kevisual/cnb'
import { Gitea } from '@kevisual/gitea'; 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'

1
src/agents/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './app.ts'

View 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,
}

View File

@@ -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"

View File

@@ -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"

View 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,
}

View File

@@ -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}
/> />
) )

View File

@@ -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}

View 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
View 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 }

View File

@@ -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"

View 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
View 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,
}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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,
}) })

View File

@@ -1,2 +1,22 @@
// @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
}

1227
src/modules/cnb-api.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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());

View File

@@ -0,0 +1 @@
export * from './use-api-query';

View 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,
});
};

View File

@@ -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,

View File

@@ -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">

View File

@@ -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 data = await stackQueryClient.fetchQuery({
queryKey: authQueryKeys.me,
queryFn: async () => {
const res = await queryLogin.getMe(); const res = await queryLogin.getMe();
if (res.code === 200) { if (res.code === 200) {
set({ me: res.data }); return res.data;
set({ isAdmin: res.data.orgs?.includes?.('admin') || false });
} }
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) {
set({ me: userInfo as UserInfo, isAdmin: userInfo.orgs?.includes?.('admin') || false });
} else { } else {
set({ me: undefined, isAdmin: false }); set({ me: undefined, isAdmin: false });
} }
} catch {
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 }),
})); }));

906
src/pages/cnb/api.ts Normal file
View 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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"source": "query-proxy-api"
}
},
/**
* 保持工作空间存活技能参数repo:代码仓库路径,例如 user/repopipelineId:流水线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/repopipelineId:流水线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/cnb-dev",
"source": "query-proxy-api"
}
},
/**
* 停止保持工作空间存活技能, 参数repo:代码仓库路径,例如 user/repopipelineId:流水线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/repopipelineId:流水线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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"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/cnb-dev",
"source": "query-proxy-api"
}
}
}
} as const;
const queryApi = createQueryApi({ api, query });
export { queryApi };

View File

@@ -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>
); );
}; };

View File

@@ -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
}
})); }));

View File

@@ -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 { 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) => {
@@ -33,15 +18,15 @@ export const ConfigPage = () => {
return ( return (
<TooltipProvider> <TooltipProvider>
<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">
@@ -105,91 +90,17 @@ 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"
type="url"
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>
{layoutStore.me && <>
<Button type="button" variant="outline" onClick={loadFromRemote}>
</Button> </Button>
<Button type="button" variant="outline" onClick={saveToRemote}> <Button type="button" variant="outline" onClick={saveToRemote} className="w-full sm:w-auto">
</Button> </Button>
</>
}
</div> </div>
</form> </form>
</CardContent> </div>
</Card> </div>
</div> </div>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -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
} }

View File

@@ -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
View 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;

View 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 || '请求失败');
}
}
}
})

View File

@@ -1,3 +1,2 @@
import App from './repos/page' import App from './repos/page'
export default App
export default App;

View File

@@ -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>

View File

@@ -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
@@ -71,33 +72,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 +127,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 +190,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 +228,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 +288,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,12 +301,15 @@ 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={() => {
{ !showReturn && openInCNB(false) }
}}>
{repo.site && ( {repo.site && (
<div <div
className="text-xs text-neutral-500 hover:text-neutral-900 hover:underline flex transition-colors" className="text-xs text-neutral-500 hover:text-neutral-900 hover:underline flex transition-colors"
onClick={() => { onClick={(e) => {
window.open(repo.site, '_blank') window.open(repo.site, '_blank')
e.stopPropagation()
}} }}
> >
<LinkIcon className="w-4 h-4 shrink-0 mr-2" /> <LinkIcon className="w-4 h-4 shrink-0 mr-2" />
@@ -313,26 +319,27 @@ export function RepoCard({ showReturn = false, repo }: RepoCardProps) {
</div> </div>
)} )}
{repo.description && ( {repo.description && (
<p className="ml-2 text-sm text-neutral-600 line-clamp-2 min-h-10 grow"> <p className="text-sm text-neutral-600 line-clamp-2 min-h-10">
{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)
}}> }}>
@@ -341,7 +348,7 @@ export function RepoCard({ showReturn = false, repo }: RepoCardProps) {
</span>} </span>}
{isMine && ( {isMine && (
<span <span
className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors cursor-pointer" className="flex items-center gap-1 hover:text-neutral-900 transition-colors cursor-pointer whitespace-nowrap"
onClick={() => { onClick={() => {
store.setSelectedSyncRepo(repo) store.setSelectedSyncRepo(repo)
store.setSyncDialogOpen(true) store.setSyncDialogOpen(true)

View File

@@ -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(() => {
@@ -64,7 +71,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 +94,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 }) => (
<Select {...field}>
<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 +129,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>

View File

@@ -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>

View File

@@ -1,10 +1,15 @@
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 {
@@ -19,11 +24,13 @@ import {
Zap, Zap,
Copy, Copy,
Check, Check,
Square Square,
Link
} 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 +42,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 +49,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 +63,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}>
<Tooltip>
<TooltipTrigger>
<div
onClick={handleClick} onClick={handleClick}
disabled={!url}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
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 === ' ') {
e.preventDefault()
handleCopy(e as any)
}
}} }}
role="button"
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" 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"
> >
{isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} {isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</div> </div>
)} )}
</button> </div>
</TooltipTrigger>
<TooltipContent>
<p>{url}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) )
} }
@@ -124,9 +127,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 +173,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 +209,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,6 +217,8 @@ 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[] = [
{ {
@@ -237,6 +285,11 @@ export function WorkspaceDetailDialog() {
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 +322,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>

View File

@@ -37,7 +37,6 @@ export const App = () => {
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,12 +46,10 @@ export const App = () => {
return 0 return 0
}) })
// 如果没有搜索词,返回排序后的列表
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
return sortedList return sortedList
} }
// 使用 Fuse.js 进行模糊搜索
const fuse = new Fuse(sortedList, { const fuse = new Fuse(sortedList, {
keys: ['name', 'path', 'description'], keys: ['name', 'path', 'description'],
threshold: 0.3, threshold: 0.3,
@@ -66,50 +63,56 @@ export const App = () => {
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"> <div className="min-h-screen bg-neutral-50 flex flex-col">
<div className="container mx-auto p-6 max-w-7xl flex-1"> <div className="container mx-auto p-4 md:p-6 max-w-7xl flex-1">
<div className="mb-8 flex items-center justify-between"> <div className="mb-6 md:mb-8 flex flex-col gap-4">
<div > <div className="flex flex-col sm:flex-row sm:justify-between gap-3">
<h1 className="text-4xl font-bold text-neutral-900 mb-2 flex gap-2 items-center"> <div className=''>
<div className="flex items-center justify-between">
<h1 className="text-2xl md:text-4xl font-bold text-neutral-900 flex gap-2 items-center">
<span className="hidden md:inline"></span>
<span className="md:hidden"></span>
<Settings className="inline-block h-5 w-5 text-neutral-400 hover:text-neutral-600 cursor-pointer" onClick={() => navigate({ to: '/config' })} /> <Settings className="inline-block h-5 w-5 text-neutral-400 hover:text-neutral-600 cursor-pointer" onClick={() => navigate({ to: '/config' })} />
</h1> </h1>
<p className="text-neutral-600"> {list.length} </p>
</div> </div>
<div className="flex items-center gap-2"> <p className="text-neutral-600 text-sm md:text-base"> {list.length} </p>
</div>
<div className="flex flex-wrap items-center gap-2 md:ml-auto">
<Button <Button
onClick={() => refresh()} onClick={() => refresh()}
variant="outline" variant="outline"
className="gap-2" className="gap-2 flex-1 sm:flex-none"
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button> </Button>
<Button <Button
onClick={() => setShowCreateDialog(true)} onClick={() => setShowCreateDialog(true)}
className="gap-2" className="gap-2 flex-1 sm:flex-none"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</Button> </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>
</div>
<div className="mb-6"> <div className="mb-4 md:mb-6">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input <Input
type="text" type="text"
placeholder="搜索仓库名称、路径或描述..." placeholder="搜索仓库..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10" className="pl-10"
@@ -117,7 +120,7 @@ export const App = () => {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{appList.map((repo) => ( {appList.map((repo) => (
<RepoCard <RepoCard
key={repo.id} key={repo.id}
@@ -135,9 +138,9 @@ export const App = () => {
)} )}
</div> </div>
<footer className="border-t border-neutral-200 bg-white py-6 mt-auto"> <footer className="border-t border-neutral-200 bg-white py-4 md:py-6 mt-auto">
<div className="container mx-auto px-6 max-w-7xl"> <div className="container mx-auto px-4 md:px-6 max-w-7xl">
<div className="flex items-center justify-between text-sm text-neutral-500"> <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>© 2026 </div>
<div className="flex items-center gap-4"> <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>

View File

@@ -11,7 +11,8 @@ export const App = () => {
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,
}))); })));
const [activeTab, setActiveTab] = useState(params.tab || "build"); const [activeTab, setActiveTab] = useState(params.tab || "build");
const tabs = [ const tabs = [
@@ -21,7 +22,7 @@ export const App = () => {
useEffect(() => { useEffect(() => {
if (params.repo) { if (params.repo) {
repoStore.getItem(params.repo); repoStore.getItem(params.repo);
repoStore.refresH({ search: params.repo, showTips: false }); repoStore.refresh({ search: params.repo, showTips: false });
} else { } else {
console.log('no repo param') console.log('no repo param')
} }
@@ -30,13 +31,13 @@ export const App = () => {
return <div>Loading...</div> return <div>Loading...</div>
} }
return ( return (
<div className="p-2 flex-col flex gap-2 h-full"> <div className="p-2 md:p-4 flex-col flex gap-2 md:gap-4 h-full">
<div className="px-4 h-full scrollbar flex-col flex gap-4 overflow-hidden"> <div className="px-2 md:px-4 h-full scrollbar flex-col flex gap-3 md:gap-4 overflow-hidden">
<div className="flex border-b mb-4"> <div className="flex border-b overflow-x-auto">
{tabs.map(tab => ( {tabs.map(tab => (
<div <div
key={tab.key} key={tab.key}
className={`px-4 py-2 cursor-pointer ${activeTab === tab.key ? 'border-b-2 border-gray-500' : ''}`} 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' : ''}`}
onClick={() => { onClick={() => {
setActiveTab(tab.key) setActiveTab(tab.key)
history.replaceState(null, '', `?repo=${params.repo}&tab=${tab.key}`) history.replaceState(null, '', `?repo=${params.repo}&tab=${tab.key}`)
@@ -48,10 +49,10 @@ export const App = () => {
</div> </div>
{activeTab === 'build' && <BuildConfig />} {activeTab === 'build' && <BuildConfig />}
{activeTab === 'info' && ( {activeTab === 'info' && (
<div className="flex flex-col gap-4 h-full"> <div className="flex flex-col gap-3 md:gap-4 h-full">
<RepoCard repo={repoStore.editRepo} showReturn /> <RepoCard repo={repoStore.editRepo} showReturn />
<div className="p-4 border rounded bg-white h-full overflow-auto scrollbar"> <div className="p-3 md: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> <pre className="whitespace-pre-wrap break-all text-xs md:text-sm">{JSON.stringify(repoStore.editRepo, null, 2)}</pre>
</div> </div>
</div> </div>
)} )}

View File

@@ -53,7 +53,7 @@ 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:
@@ -95,8 +95,6 @@ ${branch}:
imports: !reference [.common_env, imports] imports: !reference [.common_env, imports]
env: !reference [.common_env, env] env: !reference [.common_env, env]
stages: stages:
- name: 环境变量
script: printenv > ~/.env.development
- name: 启动nginx - name: 启动nginx
script: nginx script: nginx
- name: 初始化开发机 - name: 初始化开发机

View File

@@ -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 { createBuildConfig, 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[];
@@ -102,6 +102,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 +238,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 +251,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 +322,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 +338,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 +346,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 +376,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 +392,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 +414,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 +429,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 +502,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 +515,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,
@@ -488,9 +534,10 @@ export const useRepoStore = create<State>((set, get) => {
return; return;
} }
let event = toRepo ? 'api_trigger_sync_to_gitea' : 'api_trigger_sync_from_gitea'; let event = toRepo ? 'api_trigger_sync_to_gitea' : 'api_trigger_sync_from_gitea';
const res = await cnb.build.startBuild(repo, { const res = await cnbApi.cnb['cloud-build']({
repo: toRepo! || fromRepo!,
branch: 'main', branch: 'main',
env: {}, env: {} as any,
event: event, event: event,
config: createBuildConfig({ repo: toRepo! || fromRepo! }), config: createBuildConfig({ repo: toRepo! || fromRepo! }),
}) })
@@ -501,9 +548,10 @@ export const useRepoStore = create<State>((set, get) => {
} }
}, },
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' }),
}) })

View 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 || '更新失败');
}
},
}
})

View File

@@ -0,0 +1,30 @@
import { useShallow } from "zustand/shallow";
import { useMarkStore, useWorkspaceStore } from "./store";
import { useEffect } from "react";
export const App = () => {
const markStore = useMarkStore(useShallow(state => {
return {
init: state.init,
list: state.list,
}
}));
const workspaceStore = useWorkspaceStore(useShallow(state => {
return {
edit: state.edit,
setEdit: state.setEdit,
}
}));
useEffect(() => {
// @ts-ignore
markStore.init('cnb');
}, []);
console.log('markStore.list', markStore.list);
return (
<div>
<h1>Workspaces</h1>
<p>This is the workspaces page.</p>
</div>
);
};
export default App;

View File

@@ -0,0 +1,14 @@
import { useMarkStore } from '@kevisual/api/store-mark';
export { useMarkStore }
import { create } from 'zustand';
type WorkspaceState = {
edit: boolean;
setEdit: (edit: boolean) => void;
}
export const useWorkspaceStore = create<WorkspaceState>((set) => ({
edit: false,
setEdit: (edit) => set({ edit }),
}));

View File

@@ -10,7 +10,9 @@
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 ConfigGiteaRouteImport } from './routes/config/gitea' import { Route as ConfigGiteaRouteImport } from './routes/config/gitea'
@@ -20,11 +22,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/',
@@ -43,40 +55,70 @@ 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
'/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
'/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
'/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'
| '/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'
| '/config'
| '/repo'
| '/workspaces'
id:
| '__root__'
| '/'
| '/demo'
| '/login'
| '/config/gitea'
| '/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
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 +130,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 +144,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'
@@ -121,10 +177,12 @@ 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,
ConfigIndexRoute: ConfigIndexRoute, ConfigIndexRoute: ConfigIndexRoute,
RepoIndexRoute: RepoIndexRoute, RepoIndexRoute: RepoIndexRoute,
WorkspacesIndexRoute: WorkspacesIndexRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -4,18 +4,30 @@ 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 (
<QueryClientProvider client={stackQueryClient}>
<div className='h-full overflow-hidden'> <div className='h-full overflow-hidden'>
<LayoutMain /> <LayoutMain />
<AuthProvider mustLogin={false}> <AuthProvider mustLogin={false}>
<TooltipProvider> <TooltipProvider>
<main className='h-[calc(100%-3rem)] overflow-auto scrollbar'> <main className={clsx('overflow-auto scrollbar', {
'h-[calc(100%-3rem)]': store.showBaseHeader,
'h-full': !store.showBaseHeader,
})}>
<Outlet /> <Outlet />
</main> </main>
</TooltipProvider> </TooltipProvider>
@@ -23,5 +35,6 @@ function RootComponent() {
<TanStackRouterDevtools position="bottom-right" /> <TanStackRouterDevtools position="bottom-right" />
<Toaster /> <Toaster />
</div> </div>
</QueryClientProvider>
) )
} }

9
src/routes/demo.tsx Normal file
View 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 />
}

View 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 />
}

View File

@@ -4,6 +4,7 @@ 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/config';
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
const basename = isDev ? '/' : pkgs?.basename || '/'; const basename = isDev ? '/' : pkgs?.basename || '/';