Compare commits

..

6 Commits

Author SHA1 Message Date
xiongxiao
389f7a7ad2 feat: 添加侧边栏组件,整合导航项并实现可折叠功能
refactor: 更新工作空间页面和配置页面,使用侧边栏布局
chore: 删除不再使用的gitea页面
fix: 优化CNB黑色logo组件
2026-03-19 20:37:56 +08:00
xiongxiao
dd6eff9269 feat: 添加可折叠侧边栏布局,优化仓库列表和工作空间页面 2026-03-19 20:26:27 +08:00
xiongxiao
9a06364880 fix: 更新API URL路径,从cnb-dev更改为dev-cnb 2026-03-19 02:29:15 +08:00
xiongxiao
3f0733a540 refactor: 重构workspaces页面UI,使用组件化设计和Tailwind样式 2026-03-19 02:17:43 +08:00
xiongxiao
330accb822 feat: 优化mark接口文档和工作空间页面字段 2026-03-19 02:02:03 +08:00
xiongxiao
469d23b0b9 Auto commit: 2026-03-19 01:46 2026-03-19 01:46:16 +08:00
24 changed files with 1478 additions and 372 deletions

View File

@@ -1,26 +1,12 @@
# cnb center
> cnb 仓库界面很不好用所以自己写了一个纯调api的界面方便管理仓库和查看同步状态
一个应用工作台
## 主要功能
## 功能
### git仓库管理功能
- 创建仓库
- 删除仓库使用cookie
- 同步仓库
仓库列出用户所有仓库,主体显示仓库名,描述。每一个仓库具备更快捷的功能模块。
启动是启动云开发环境。
![intro](./public/images/repo-info.png)
配置项
![config](./public/images/config-info.png)
示例cookie配置
```sh
CNBSESSION=1770622649.1935321989751226368.0bc7fc786f7052cb2b077c00ded651a5945d46d1e466f4fafa14ede554da14a0;csrfkey=158893308
```
1. 对话管理
2. 仓库管理
3. 云开发
4. 应用管理
5. Agent管理
6. 配置

View File

@@ -40,9 +40,11 @@
"lucide-react": "^0.577.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
"re-resizable": "^6.11.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"react-resizable": "^3.1.3",
"sonner": "^2.0.7",
"vite-plugin-pwa": "^1.2.0",
"zod": "^4.3.6",
@@ -63,6 +65,7 @@
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-resizable": "^3.0.8",
"@vitejs/plugin-react": "^6.0.1",
"dotenv": "^17.3.1",
"tailwind-merge": "^3.5.0",

View File

@@ -0,0 +1,270 @@
'use client'
import { useNavigate, useLocation } from '@tanstack/react-router'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'
import { Resizable } from 're-resizable'
export interface NavItem {
title: string
path: string
icon?: React.ReactNode
isDeveloping?: boolean
badge?: string
hidden?: boolean
children?: NavItem[]
}
export interface SidebarProps {
items: NavItem[]
className?: string
children?: React.ReactNode
logo?: React.ReactNode
title?: React.ReactNode
defaultCollapsed?: boolean
defaultWidth?: number
minWidth?: number
maxWidth?: number
}
export function Sidebar({
items,
className,
children,
logo,
title,
defaultCollapsed = false,
defaultWidth = 208,
minWidth = 120,
maxWidth = 400,
}: SidebarProps) {
const navigate = useNavigate()
const location = useLocation()
const currentPath = location.pathname
const [collapsed, setCollapsed] = useState(defaultCollapsed)
const [sidebarWidth, setSidebarWidth] = useState(defaultWidth)
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const [developingDialog, setDevelopingDialog] = useState<{ open: boolean; title: string }>({
open: false,
title: '',
})
const toggleGroup = (path: string) => {
const newExpanded = new Set(expandedGroups)
if (newExpanded.has(path)) {
newExpanded.delete(path)
} else {
newExpanded.add(path)
}
setExpandedGroups(newExpanded)
}
const handleNavClick = (item: NavItem) => {
if (item.isDeveloping) {
setDevelopingDialog({ open: true, title: item.title })
} else {
navigate({ to: item.path })
}
}
// 判断当前路径是否激活(以导航路径开头)
const isActive = (path: string) => {
if (path === '/') {
return currentPath === '/'
}
return currentPath.startsWith(path);
}
// 渲染导航项
const renderNavItem = (item: NavItem, isChild = false) => {
if (item.hidden) return null
const hasChildren = item.children && item.children.length > 0
const isExpanded = expandedGroups.has(item.path)
const active = isActive(item.path)
return (
<li key={item.path} className='list-none'>
{hasChildren ? (
// 父菜单项(可展开)
<div>
<button
onClick={() => toggleGroup(item.path)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors cursor-pointer',
active
? 'bg-gray-200 text-gray-900 font-medium'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900',
collapsed && 'justify-center px-2'
)}
title={collapsed ? item.title : undefined}
>
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
{!collapsed && (
<>
<span className="flex-1 text-left">{item.title}</span>
<ChevronDown className={cn('w-4 h-4 transition-transform', isExpanded && 'rotate-180')} />
</>
)}
</button>
{/* 子菜单 */}
{!collapsed && isExpanded && item.children && (
<ul className="ml-6 mt-1 space-y-1 list-none">
{item.children.map(child => renderNavItem(child, true))}
</ul>
)}
</div>
) : (
// 普通菜单项
<button
onClick={() => handleNavClick(item)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors cursor-pointer',
active
? 'bg-gray-200 text-gray-900 font-medium'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900',
item.isDeveloping && 'opacity-60',
isChild && 'py-1.5',
collapsed && 'justify-center px-2'
)}
title={collapsed ? item.title : undefined}
>
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
{!collapsed && (
<>
<span className="flex-1 text-left">{item.title}</span>
{item.isDeveloping && (
<span className="text-xs bg-gray-200 text-gray-600 px-1.5 py-0.5 rounded">
dev
</span>
)}
{item.badge && !item.isDeveloping && (
<span className="text-xs bg-gray-200 text-gray-700 px-1.5 py-0.5 rounded">
{item.badge}
</span>
)}
</>
)}
</button>
)}
</li>
)
}
return (
<>
<div className={cn('flex h-full', className)}>
{/* 侧边栏 */}
{!collapsed ? (
<Resizable
defaultSize={{ width: sidebarWidth, height: '100%' }}
minWidth={minWidth}
maxWidth={maxWidth}
onResizeStop={(_e, _direction, ref, _d) => {
setSidebarWidth(ref.offsetWidth)
}}
enable={{
right: true,
}}
handleComponent={{
right: <div className="w-1 h-full cursor-col-resize hover:bg-blue-400 transition-colors" />,
}}
>
<aside
className={cn(
'border-r bg-white flex-shrink-0 flex flex-col'
)}
style={{ width: sidebarWidth }}
>
{/* Logo 区域 */}
<div className={cn(
'h-12 flex items-center border-b px-3'
)}>
{logo && (
<div className="flex-shrink-0 flex items-center gap-2">{logo}</div>
)}
{title && (
<span className="text-sm font-medium text-gray-900 truncate ml-1">{title}</span>
)}
<button
onClick={() => setCollapsed(true)}
className="ml-auto flex-shrink-0 p-1 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
title="收起"
>
<ChevronLeft className="w-4 h-4 text-gray-500" />
</button>
</div>
{/* 导航区域 */}
<nav className="flex-1 p-2 overflow-y-auto">
<ul className="space-y-1 list-none">
{items.map((item) => renderNavItem(item))}
</ul>
</nav>
</aside>
</Resizable>
) : (
// 收起状态
<aside className="w-14 border-r bg-white flex-shrink-0 flex flex-col">
{/* Logo 区域 */}
<div className="h-12 flex items-center justify-center border-b px-2">
<div className="group relative flex-shrink-0">
<button
onClick={() => setCollapsed(false)}
className="p-1 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
title="展开"
>
<span className="group-hover:hidden">{logo}</span>
<ChevronRight className="w-5 h-5 hidden group-hover:block text-gray-600" />
</button>
</div>
</div>
{/* 导航区域 */}
<nav className="flex-1 p-2 overflow-y-auto">
<ul className="space-y-1 list-none">
{items.map((item) => renderNavItem(item))}
</ul>
</nav>
</aside>
)}
{/* 主内容区域 */}
<main className="flex-1 overflow-auto h-full bg-gray-50">
{children}
</main>
</div>
{/* 开发中弹窗 */}
<Dialog
open={developingDialog.open}
onOpenChange={(open) => setDevelopingDialog({ open, title: '' })}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{developingDialog.title} - </DialogTitle>
<DialogDescription>
访
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setDevelopingDialog({ open: false, title: '' })}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

378
src/modules/mark-api.ts Normal file
View File

@@ -0,0 +1,378 @@
import { createQueryApi } from '@kevisual/query/api';
const api = {
"mark": {
/**
* 获取mark列表
*
* @param data - Request parameters
* @param data.page - {number} 页码
* @param data.pageSize - {number} 每页数量
* @param data.search - {string} 搜索关键词
* @param data.markType - {string} mark类型,simple,wallnote,md,draw等
* @param data.sort - {"DESC" | "ASC"} 排序字段
*/
"list": {
"path": "mark",
"key": "list",
"description": "获取mark列表",
"metadata": {
"args": {
"page": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "页码",
"type": "number",
"optional": true
},
"pageSize": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "每页数量",
"type": "number",
"optional": true
},
"search": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "搜索关键词",
"type": "string",
"optional": true
},
"markType": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "mark类型,simple,wallnote,md,draw等",
"type": "string",
"optional": true
},
"sort": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"default": "DESC",
"description": "排序字段",
"type": "string",
"enum": [
"DESC",
"ASC"
],
"optional": true
}
},
"url": "/api/router",
"source": "query-proxy-api"
}
},
/**
* 获取mark版本信息
*
* @param data - Request parameters
* @param data.id - {string} mark id
*/
"getVersion": {
"path": "mark",
"key": "getVersion",
"description": "获取mark版本信息",
"metadata": {
"args": {
"id": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "mark id"
}
},
"url": "/api/router",
"source": "query-proxy-api"
}
},
/**
* 获取mark详情
*
* @param data - Request parameters
* @param data.id - {string} mark id
*/
"get": {
"path": "mark",
"key": "get",
"description": "获取mark详情",
"metadata": {
"args": {
"id": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "mark id"
}
},
"url": "/api/router",
"source": "query-proxy-api"
}
},
/**
* 更新mark内容
*
* @param data - Request parameters
* @param data.id - {string} mark id
* @param data.data - {object}
*/
"update": {
"path": "mark",
"key": "update",
"description": "更新mark内容",
"metadata": {
"args": {
"id": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "mark id"
},
"data": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"title": {
"default": "",
"type": "string"
},
"tags": {
"default": []
},
"link": {
"default": "",
"type": "string"
},
"summary": {
"default": "",
"type": "string"
},
"description": {
"default": "",
"type": "string"
}
},
"required": [
"title",
"tags",
"link",
"summary",
"description"
],
"additionalProperties": false
}
},
"url": "/api/router",
"source": "query-proxy-api"
}
},
/**
* 更新mark节点支持更新和删除操作
*
* @param data - Request parameters
* @param data.id - {string} mark id
* @param data.operate - {"update" | "delete"} 节点操作类型update或delete
* @param data.data - {object} 要更新的节点数据
*/
"updateNode": {
"path": "mark",
"key": "updateNode",
"description": "更新mark节点支持更新和删除操作",
"metadata": {
"args": {
"id": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "mark id"
},
"operate": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"default": "update",
"description": "节点操作类型update或delete",
"type": "string",
"enum": [
"update",
"delete"
],
"optional": true
},
"data": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "节点id"
},
"node": {
"description": "要更新的节点数据"
}
},
"required": [
"id",
"node"
],
"additionalProperties": false,
"description": "要更新的节点数据"
}
},
"url": "/api/router",
"source": "query-proxy-api"
}
},
/**
* 批量更新mark节点支持更新和删除操作
*
* @param data - Request parameters
* @param data.id - {string} mark id
* @param data.nodeOperateList - {array} 要更新的节点列表
*/
"updateNodes": {
"path": "mark",
"key": "updateNodes",
"description": "批量更新mark节点支持更新和删除操作",
"metadata": {
"args": {
"id": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "mark id"
},
"nodeOperateList": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"items": {
"type": "object",
"properties": {
"operate": {
"default": "update",
"description": "节点操作类型update或delete",
"type": "string",
"enum": [
"update",
"delete"
]
},
"node": {
"description": "要更新的节点数据"
}
},
"required": [
"operate",
"node"
],
"additionalProperties": false
},
"description": "要更新的节点列表"
}
},
"url": "/api/router",
"source": "query-proxy-api"
}
},
/**
* @param data - Request parameters
* @param data.id - {string} mark id
*/
"delete": {
"path": "mark",
"key": "delete",
"metadata": {
"args": {
"id": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string",
"description": "mark id"
}
},
"url": "/api/router",
"source": "query-proxy-api"
}
},
/**
* 创建一个新的mark.
*
* @param data - Request parameters
* @param data.title - {string} 标题
* @param data.tags - {unknown} 标签
* @param data.link - {string} 链接
* @param data.summary - {string} 摘要
* @param data.description - {string} 描述
* @param data.markType - {string} mark类型
* @param data.config - {unknown} 配置
* @param data.data - {unknown} 数据
*/
"create": {
"path": "mark",
"key": "create",
"description": "创建一个新的mark.",
"metadata": {
"args": {
"title": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"default": "",
"description": "标题",
"type": "string",
"optional": true
},
"tags": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"items": { "type": "string" },
"default": [],
"description": "标签",
"optional": true
},
"link": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"default": "",
"description": "链接",
"type": "string",
"optional": true
},
"summary": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"default": "",
"description": "摘要",
"type": "string",
"optional": true
},
"description": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"default": "",
"description": "描述",
"type": "string",
"optional": true
},
"markType": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"default": "md",
"description": "mark类型",
"type": "string",
"optional": true
},
"config": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"default": {},
"description": "配置",
"optional": true
},
"data": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"default": {},
"description": "数据",
"optional": true
}
},
"url": "/api/router",
"source": "query-proxy-api"
}
},
/**
* 获取mark菜单
*/
"getMenu": {
"path": "mark",
"key": "getMenu",
"description": "获取mark菜单",
"metadata": {
"url": "/api/router",
"source": "query-proxy-api"
}
}
}
} as const;
const queryApi = createQueryApi({ api });
export { queryApi };

View File

@@ -36,7 +36,7 @@ const api = {
"skill": "cnb-login-verify",
"title": "CNB 登录验证信息",
"summary": "验证 CNB 登录信息是否有效",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -79,7 +79,7 @@ const api = {
"skill": "list-repos",
"title": "列出cnb代码仓库",
"summary": "列出cnb代码仓库, 可选flags参数如 KnowledgeBase",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -121,7 +121,7 @@ const api = {
"skill": "create-repo",
"title": "创建代码仓库",
"summary": "创建一个新的代码仓库",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -168,7 +168,7 @@ const api = {
"skill": "create-repo-file",
"title": "在代码仓库中创建文件",
"summary": "在代码仓库中创建文件, encoding 可选,默认 raw",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -196,7 +196,7 @@ const api = {
"skill": "delete-repo",
"title": "删除代码仓库",
"summary": "删除一个代码仓库",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -215,7 +215,7 @@ const api = {
"skill": "clean-closed-workspace",
"title": "清理已关闭的cnb工作空间",
"summary": "批量删除已停止的cnb工作空间释放资源",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -244,7 +244,7 @@ const api = {
"description": "流水线ID例如 cnb-708-1ji9sog7o-001"
}
},
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -273,7 +273,7 @@ const api = {
"description": "流水线ID例如 cnb-708-1ji9sog7o-001"
}
},
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -291,7 +291,7 @@ const api = {
"skill": "keep-alive-current-workspace",
"title": "保持当前工作空间存活",
"summary": "保持当前工作空间存活,防止被关闭或释放资源",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -333,7 +333,7 @@ const api = {
"skill": "start-workspace",
"title": "启动cnb工作空间",
"summary": "启动cnb工作空间",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -390,7 +390,7 @@ const api = {
"skill": "list-workspace",
"title": "列出cnb工作空间",
"summary": "列出cnb工作空间列表支持按状态过滤 status 可选值 running 或 closed",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -424,7 +424,7 @@ const api = {
"skill": "get-workspace",
"title": "获取工作空间详情",
"summary": "获取工作空间详细信息",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -470,7 +470,7 @@ const api = {
"skill": "delete-workspace",
"title": "删除工作空间",
"summary": "删除工作空间pipelineId 和 sn 二选一",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -506,7 +506,7 @@ const api = {
"skill": "stop-workspace",
"title": "停止工作空间",
"summary": "停止运行中的工作空间",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -535,7 +535,7 @@ const api = {
"skill": "get-cnb-port-uri",
"title": "获取当前cnb工作空间的port代理uri",
"summary": "获取当前cnb工作空间的port代理uri用于端口转发",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -592,7 +592,7 @@ const api = {
"skill": "get-cnb-vscode-uri",
"title": "获取当前cnb工作空间的编辑器访问地址",
"summary": "获取当前cnb工作空间的vscode代理uri用于在浏览器中访问vscode包含多种访问方式如web、vscode、codebuddy、cursor、ssh",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -620,7 +620,7 @@ const api = {
"skill": "set-cnb-cookie",
"title": "设置当前cnb工作空间的cookie环境变量",
"summary": "设置当前cnb工作空间的cookie环境变量用于界面操作定制模块功能,例子CNBSESSION=xxxx;csrfkey=2222xxxx;",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -639,7 +639,7 @@ const api = {
"skill": "get-cnb-cookie",
"title": "获取当前cnb工作空间的cookie环境变量",
"summary": "获取当前cnb工作空间的cookie环境变量用于界面操作定制模块功能",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -674,7 +674,7 @@ const api = {
"skill": "cnb-ai-chat",
"title": "调用cnb的知识库ai对话功能进行聊天",
"summary": "调用cnb的知识库ai对话功能进行聊天基于cnb提供的ai能力",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -709,7 +709,7 @@ const api = {
"skill": "cnb-rag-query",
"title": "调用cnb的知识库RAG查询功能进行问答",
"summary": "调用cnb的知识库RAG查询功能进行问答基于cnb提供的知识库能力",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -779,7 +779,7 @@ const api = {
"skill": "list-issues",
"title": "查询 Issue 列表",
"summary": "查询 Issue 列表",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -847,7 +847,7 @@ const api = {
"skill": "create-issue",
"title": "创建 Issue",
"summary": "创建一个新的 Issue",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
},
@@ -895,7 +895,7 @@ const api = {
"skill": "complete-issue",
"title": "完成 CNB的任务Issue",
"summary": "完成一个 Issue将 state 改为 closed",
"url": "/root/v1/cnb-dev",
"url": "/root/v1/dev-cnb",
"source": "query-proxy-api"
}
}

View File

@@ -4,7 +4,7 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Info } from 'lucide-react';
import { SidebarLayout } from '../sidebar/components';
export const ConfigPage = () => {
const { config, setConfig, saveToRemote, loadFromRemote } = useConfigStore();
const handleSubmit = (e: React.FormEvent) => {
@@ -17,13 +17,13 @@ export const ConfigPage = () => {
};
return (
<TooltipProvider>
<SidebarLayout>
<div className="container mx-auto max-w-2xl py-4 md:py-8 px-4">
<div className="bg-white md:rounded-lg md:border md:shadow-sm">
<div className="p-4 md:p-6 border-b">
<h1 className="text-xl md:text-2xl font-bold">CNB </h1>
<p className="text-sm text-muted-foreground mt-1">
CNB API
CNB API
</p>
</div>
<div className="p-4 md:p-6">
@@ -68,7 +68,7 @@ export const ConfigPage = () => {
</TooltipTrigger>
<TooltipContent>
<p>
Cookie
Cookie 7
<a
href="https://cnb.cool/kevisual/cnb-live-extension"
target="_blank"
@@ -102,7 +102,7 @@ export const ConfigPage = () => {
</div>
</div>
</div>
</TooltipProvider>
</SidebarLayout>
);
};

View File

@@ -1 +0,0 @@
// gitea.tsx

View File

@@ -47,8 +47,6 @@ export function RepoCard({ showReturn = false, repo }: RepoCardProps) {
return store.workspaceList.find(ws => ws.slug === repo.path)
}, [store.workspaceList, repo.path])
const isWorkspaceActive = !!workspace
const owner = repo.path.split('/')[0]
const isMine = myOrgs.includes(owner)
const navigate = useNavigate();
const isKnowledge = repo?.flags === "KnowledgeBase"
const createKnow = async () => {
@@ -346,18 +344,6 @@ export function RepoCard({ showReturn = false, repo }: RepoCardProps) {
<Play className="w-3.5 h-3.5" />
<span className="font-medium"></span>
</span>}
{isMine && (
<span
className="flex items-center gap-1 hover:text-neutral-900 transition-colors cursor-pointer whitespace-nowrap"
onClick={() => {
store.setSelectedSyncRepo(repo)
store.setSyncDialogOpen(true)
}}
>
<RefreshCw className="w-3.5 h-3.5" />
<span className="font-medium"></span>
</span>
)}
</div>
</div>
</Card>

View File

@@ -49,7 +49,7 @@ export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps)
path: '',
license: '',
description: '',
visibility: 'Public'
visibility: 'public'
})
}
}, [open, reset])
@@ -58,7 +58,10 @@ export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps)
setIsSubmitting(true)
try {
const submitData = {
...data,
path: data.path.trim(),
license: data.license.trim(),
description: data.description.trim(),
visibility: data.visibility,
}
await createRepo(submitData)
@@ -104,16 +107,16 @@ export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps)
<Controller
name="visibility"
control={control}
defaultValue="Public"
defaultValue="public"
render={({ field: { onChange, value } }) => (
<Select onValueChange={onChange} value={value}>
<SelectTrigger id="visibility">
<SelectValue placeholder="选择可见性" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Public"> (public)</SelectItem>
<SelectItem value="Private"> (private)</SelectItem>
<SelectItem value="Protected"> (protected)</SelectItem>
<SelectItem value="public"> (public)</SelectItem>
<SelectItem value="private"> (private)</SelectItem>
<SelectItem value="protected"> (protected)</SelectItem>
</SelectContent>
</Select>
)}

View File

@@ -1,115 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useRepoStore } from '../store'
import { useState, useEffect } from 'react'
import { useShallow } from 'zustand/shallow'
import { get, set } from 'idb-keyval'
import { gitea } from '@/agents/app';
import { toast } from 'sonner'
const SYNC_REPO_STORAGE_KEY = 'sync-repo-mapping'
export function SyncRepoDialog() {
const { syncDialogOpen, setSyncDialogOpen, selectedSyncRepo, buildSync } = useRepoStore(useShallow((state) => ({
syncDialogOpen: state.syncDialogOpen,
setSyncDialogOpen: state.setSyncDialogOpen,
selectedSyncRepo: state.selectedSyncRepo,
buildSync: state.buildSync,
})))
const [toRepo, setToRepo] = useState('')
useEffect(() => {
const loadSavedMapping = async () => {
if (syncDialogOpen && selectedSyncRepo) {
const currentPath = selectedSyncRepo.path || ''
// 从 idb-keyval 获取存储的映射
const mapping = await get<Record<string, string>>(SYNC_REPO_STORAGE_KEY)
// 如果有存储的值,使用存储的值,否则使用当前仓库路径
setToRepo(mapping?.[currentPath] || currentPath)
}
}
loadSavedMapping()
}, [syncDialogOpen, selectedSyncRepo])
const handleSync = async () => {
if (!selectedSyncRepo || !toRepo.trim()) {
return
}
// 保存映射到 idb-keyval
const currentPath = selectedSyncRepo.path || ''
const mapping = await get<Record<string, string>>(SYNC_REPO_STORAGE_KEY) || {}
mapping[currentPath] = toRepo
await set(SYNC_REPO_STORAGE_KEY, mapping)
await buildSync(selectedSyncRepo, { toRepo })
setSyncDialogOpen(false)
}
const onCreateRepo = async () => {
if (!toRepo.trim()) {
return
}
try {
const res = await gitea.repo.createRepo({ name: toRepo })
if (res.code !== 200 && res.code !== 409) {
// 409 表示仓库已存在,可以继续同步
throw new Error(`${res.message}`)
}
if (res.code === 200) {
toast.success('仓库创建成功,正在同步...')
} else {
toast.warning('仓库已存在,正在同步...')
}
handleSync()
} catch (error) {
console.error('创建仓库失败:', error)
}
}
return (
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
<DialogContent className="sm:max-w-125">
<DialogHeader>
<DialogTitle> Gitea</DialogTitle>
<DialogDescription>
<span className="font-semibold text-neutral-900">{selectedSyncRepo?.path}</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="toRepo"></Label>
<Input
id="toRepo"
placeholder="例如: kevisual/my-repo"
value={toRepo}
onChange={(e) => setToRepo(e.target.value)}
/>
<p className="text-xs text-neutral-500">
格式: owner/repo-name
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setSyncDialogOpen(false)}
>
</Button>
<Button onClick={onCreateRepo} disabled={!toRepo.trim()}>
</Button>
<Button
onClick={handleSync}
disabled={!toRepo.trim()}
>
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -5,7 +5,6 @@ import { RepoCard } from './components/RepoCard'
import { EditRepoDialog } from './modules/EditRepoDialog'
import { CreateRepoDialog } from './modules/CreateRepoDialog'
import { WorkspaceDetailDialog } from './modules/WorkspaceDetailDialog'
import { SyncRepoDialog } from './modules/SyncRepoDialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ExternalLinkIcon, Plus, RefreshCw, Search, Settings } from 'lucide-react'
@@ -13,6 +12,8 @@ import Fuse from 'fuse.js'
import { useNavigate } from '@tanstack/react-router'
import { useLayoutStore } from '../auth/store'
import { useConfigStore } from '../config/store'
import { SidebarLayout } from '../sidebar/components'
import { Checkbox } from '@/components/ui/checkbox'
export const App = () => {
const { list, refresh, loading, workspaceList, setShowCreateDialog } = useRepoStore(useShallow((state) => ({
@@ -23,6 +24,7 @@ export const App = () => {
setShowCreateDialog: state.setShowCreateDialog,
})))
const [searchQuery, setSearchQuery] = useState('')
const [filterDev, setFilterDev] = useState(false)
const navigate = useNavigate();
const me = useLayoutStore(state => state.me)
const configStore = useConfigStore(useShallow(state => ({ checkConfig: state.checkConfig })))
@@ -46,11 +48,19 @@ export const App = () => {
return 0
})
if (!searchQuery.trim()) {
return sortedList
let filteredList = sortedList
if (filterDev) {
filteredList = sortedList.filter(repo => {
const topics = repo.topics ? repo.topics.split(',').map(t => t.trim().toLowerCase()) : []
return topics.some(topic => topic.includes('dev'))
})
}
const fuse = new Fuse(sortedList, {
if (!searchQuery.trim()) {
return filteredList
}
const fuse = new Fuse(filteredList, {
keys: ['name', 'path', 'description'],
threshold: 0.3,
includeScore: true
@@ -58,10 +68,11 @@ export const App = () => {
const results = fuse.search(searchQuery)
return results.map(result => result.item)
}, [list, workspaceList, searchQuery])
}, [list, workspaceList, searchQuery, filterDev])
const isCNB = location.hostname.includes('cnb.run')
return (
<SidebarLayout>
<div className="min-h-screen bg-neutral-50 flex flex-col">
<div className="container mx-auto p-4 md:p-6 max-w-7xl flex-1">
<div className="mb-6 md:mb-8 flex flex-col gap-4">
@@ -74,7 +85,9 @@ export const App = () => {
<Settings className="inline-block h-5 w-5 text-neutral-400 hover:text-neutral-600 cursor-pointer" onClick={() => navigate({ to: '/config' })} />
</h1>
</div>
<p className="text-neutral-600 text-sm md:text-base"> {list.length} </p>
<p className="text-neutral-600 text-sm md:text-base">
{filterDev ? `显示 ${appList.length} 个 dev 仓库` : `${list.length} 个仓库`}
</p>
</div>
<div className="flex flex-wrap items-center gap-2 md:ml-auto">
<Button
@@ -108,7 +121,8 @@ export const App = () => {
</div>
<div className="mb-4 md:mb-6">
<div className="relative">
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
@@ -118,6 +132,20 @@ export const App = () => {
className="pl-10"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="filter-dev"
checked={filterDev}
onCheckedChange={(checked) => setFilterDev(checked === true)}
/>
<label
htmlFor="filter-dev"
className="text-sm text-neutral-600 cursor-pointer select-none"
>
dev
</label>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
@@ -153,6 +181,7 @@ export const App = () => {
<CommonRepoDialog />
</div>
</SidebarLayout>
)
}
@@ -176,7 +205,6 @@ export const CommonRepoDialog = () => {
onOpenChange={setShowCreateDialog}
/>
<WorkspaceDetailDialog />
<SyncRepoDialog />
</>
)
}

View File

@@ -5,6 +5,7 @@ import { useShallow } from "zustand/shallow";
import BuildConfig from "../components/BuildConfig";
import { CommonRepoDialog } from "../page";
import { RepoCard } from "../components/RepoCard";
import { SidebarLayout } from "@/pages/sidebar/components";
export const App = () => {
const params = useSearch({ strict: false }) as { repo?: string, tab?: string };
@@ -13,6 +14,7 @@ export const App = () => {
editRepo: state.editRepo,
refresh: state.refresh,
loading: state.loading,
buildConfig: state.buildConfig,
})));
const [activeTab, setActiveTab] = useState(params.tab || "build");
const tabs = [
@@ -21,7 +23,11 @@ export const App = () => {
]
useEffect(() => {
if (params.repo) {
if(repoStore.buildConfig?.repo !== params.repo) {
repoStore.getItem(params.repo);
}
console.log('refreshing repo',repoStore.buildConfig, params.repo)
// repoStore.getItem(params.repo);
repoStore.refresh({ search: params.repo, showTips: false });
} else {
console.log('no repo param')
@@ -31,9 +37,10 @@ export const App = () => {
return <div>Loading...</div>
}
return (
<SidebarLayout>
<div className="p-2 md:p-4 flex-col flex gap-2 md:gap-4 h-full">
<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 overflow-x-auto">
<div className="flex border-b overflow-x-auto h-12 shrink-0">
{tabs.map(tab => (
<div
key={tab.key}
@@ -59,6 +66,7 @@ export const App = () => {
</div>
<CommonRepoDialog />
</div>
</SidebarLayout>
)
}

View File

@@ -57,28 +57,11 @@ export const createDevConfig = (params: { repo?: string, event?: string, branch?
return `##### 配置开始,保留注释 #####
.common_env: &common_env
env:
# 使用环境变量管理密钥,推荐使用密钥仓库管理密钥, 详情见 readme.md
# 使用仓库密钥时,注释
## 可选 API-Key 配置(按需取消注释)
# MINIMAX_API_KEY: '' # Minimax 模型
# ZHIPU_API_KEY: '' # 智谱 AI
# BAILIAN_CODE_API_KEY: '' # 阿里云百炼
# VOLCENGINE_API_KEY: '' # 火山引擎
# CNB_API_KEY: '' # CNB API
# CNB_COOKIE: '' # 可选配置用cnb.cool的cookie
# 可选应用配置
# FEISHU_APP_ID: '' # 飞书应用 ID
# FEISHU_APP_SECRET: '' # 飞书应用密钥
USERNAME: root
ASSISTANT_CONFIG_DIR: /workspace/kevisual # ASSISTANT_CONFIG_DIR 环境变量指定了配置文件所在的目录
# CNB_KEVISUAL_ORG: kevisual # 私密仓库使用环境配置默认即可默认为当前用户组CNB_GROUP_SLUG
# CNB_KEVISUAL_APP: assistant-app # 可选配置(默认即可)
# CNB_OPENCLAW: openclaw # 仓库名(默认即可)
# CNB_OPENWEBUI: open-webui # 仓库名(默认即可)
imports:
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
- https://cnb.cool/\${CNB_GROUP_SLUG}/env/-/blob/main/.env
# - https://cnb.cool/\${CNB_GROUP_SLUG}/env/-/blob/main/ssh.yml
# - https://cnb.cool/\${CNB_GROUP_SLUG}/env/-/blob/main/ssh-config.yml
##### 配置结束 #####
@@ -89,19 +72,29 @@ ${branch}:
services:
- vscode
- docker
runner:
cpus: 16
#tags: cnb:arch:amd64:gpu
imports: !reference [.common_env, imports]
env: !reference [.common_env, env]
runner:
cpus: $RUN_CPU
#tags: cnb:arch:amd64:gpu
stages:
- name: 安装dev-cnb的仓库代码模块
script: |
cd /workspace && find . -mindepth 1 -delete
git init
git remote add origin https://cnb.cool/kevisual/dev-cnb
git fetch origin main
git reset --hard origin/main
- name: 启动nginx
script: nginx
- name: 启动搜索服务
script: zsh -i -c 'bun src/cli.ts init start-meilisearch'
- name: 初始化开发机
script: zsh /workspace/scripts/init.sh
script: zsh -i -c 'bun run start'
- name: 启动当前工作区
script: zsh -i -c 'cloud cnb keep-alive-current-workspace'
# endStages:
# - name: 结束阶段
# script: bun /workspace/scripts/end.ts
# script: zsh -i -c 'bun run end'
`
}

View File

@@ -3,7 +3,7 @@ import { query } from '@/modules/query';
import { toast } from 'sonner';
import { queryApi as cnbApi } from '@/modules/cnb-api'
import { WorkspaceInfo } from '@kevisual/cnb'
import { createBuildConfig, createCommitBlankConfig, createDevConfig } from './build';
import { createCommitBlankConfig, createDevConfig } from './build';
import { useLayoutStore } from '@/pages/auth/store';
import { Query } from '@kevisual/query';
interface DisplayModule {
@@ -94,7 +94,6 @@ type State = {
setSyncDialogOpen: (open: boolean) => void;
selectedSyncRepo: Data | null;
setSelectedSyncRepo: (repo: Data | null) => void;
buildSync: (data: Partial<Data>, params: { toRepo?: string, fromRepo?: string }) => Promise<any>;
buildUpdate: (data: Partial<Data>, params?: any) => Promise<any>;
getItem: (repo: string) => Promise<any>;
buildConfig: BuildConfig | null;
@@ -525,28 +524,6 @@ export const useRepoStore = create<State>((set, get) => {
}
},
workspaceLink: {},
buildSync: async (data, params) => {
const repo = data.path!;
const toRepo = params.toRepo;
const fromRepo = params.fromRepo;
if (!toRepo && !fromRepo) {
toast.error('请选择同步的目标仓库或来源仓库')
return;
}
let event = toRepo ? 'api_trigger_sync_to_gitea' : 'api_trigger_sync_from_gitea';
const res = await cnbApi.cnb['cloud-build']({
repo: toRepo! || fromRepo!,
branch: 'main',
env: {} as any,
event: event,
config: createBuildConfig({ repo: toRepo! || fromRepo! }),
})
if (res.code === 200) {
toast.success('同步提交成功')
} else {
toast.error(res.message || '同步提交失败')
}
},
buildUpdate: async (data) => {
const res = await cnbApi.cnb['cloud-build']({
repo: data.path!,

View File

@@ -0,0 +1,11 @@
export const Logo = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg {...props} width="320" height="320" viewBox="0 0 320 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M228.906 40.2412C229.882 37.5108 228.906 34.3903 226.759 32.44C219.342 26.004 200.799 12.3519 173.082 10.4016C141.852 8.06121 122.528 16.4475 112.769 22.6885C108.474 25.4189 108.279 31.4649 112.183 34.3903L191.625 96.2149C198.652 101.676 208.997 98.5553 211.729 90.169L228.711 40.2412H228.906Z" fill="black" />
<path d="M32.9381 223.564C29.6199 225.71 28.2536 229.805 29.2295 233.511C32.1573 244.432 41.3312 266.861 66.9009 287.534C92.4706 308.012 122.725 310.353 135.607 309.963C139.511 309.963 142.829 307.427 144 303.722L194.945 142.627C198.653 130.925 185.576 121.173 175.426 127.999L32.9381 223.564Z" fill="black" />
<path d="M70.2169 53.4955C67.6794 52.5203 64.9468 52.7153 62.6045 53.8855C53.2355 58.9563 29.032 74.7538 16.54 107.324C6.78054 132.288 10.0987 159.982 12.8314 173.439C13.6121 177.925 18.2967 180.46 22.5908 178.705L175.424 119.026C186.354 114.735 186.354 99.3276 175.424 95.0369L70.2169 53.4955Z" fill="black" />
<path d="M297.03 168.968C301.519 171.893 307.57 169.358 308.351 164.092C310.303 150.05 312.06 125.866 304.057 107.338C293.321 82.9591 274.974 67.7468 266.19 61.7008C263.458 59.7505 259.749 59.9456 257.212 62.2859L218.564 96.4162C212.318 102.072 212.904 112.019 219.931 116.699L297.03 168.968Z" fill="black" />
<path d="M189.089 299.428C188.699 303.914 192.603 307.814 197.092 307.229C211.731 305.669 241.79 299.818 264.237 278.365C286.098 257.496 293.32 232.728 295.272 222.781C295.858 220.051 295.272 217.32 293.515 215.175L225.98 131.897C218.758 122.925 204.119 127.411 203.143 138.918L189.089 299.233V299.428Z" fill="black" />
</svg>
)
}

View File

@@ -0,0 +1,35 @@
import { FolderKanban, LayoutDashboard, Settings, PlayCircle } from 'lucide-react'
import { Sidebar, type NavItem } from '@/components/a/Sidebar'
import { Logo } from './CNBBlackLogo.tsx'
const navItems: NavItem[] = [
{
title: '仓库管理',
path: '/',
icon: <FolderKanban className="w-5 h-5" />,
},
{
title: '工作空间',
path: '/workspaces',
icon: <LayoutDashboard className="w-5 h-5" />,
},
{
title: '应用配置',
path: '/config',
icon: <Settings className="w-5 h-5" />,
},
{
title: '其他',
path: '/demo',
icon: <PlayCircle className="w-5 h-5" />,
isDeveloping: true,
},
]
export function SidebarLayout({ children }: { children: React.ReactNode }) {
return (
<Sidebar items={navItems} title='云原生' logo={<Logo className='w-6 h-6' />}>
{children}
</Sidebar>
)
}

View File

@@ -0,0 +1 @@
export { SidebarLayout } from './Sidebar'

View File

View File

@@ -0,0 +1,105 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { TagInput } from "./TagInput";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function CreateDialog({ open, onClose, onSubmit }: {
open: boolean;
onClose: () => void;
onSubmit: (data: { title: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
}) {
const [title, setTitle] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [link, setLink] = useState('');
const [summary, setSummary] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (!title.trim()) {
alert('请输入标题');
return;
}
setSubmitting(true);
try {
await onSubmit({
title: title.trim(),
tags,
link: link.trim(),
summary: summary.trim(),
description: description.trim(),
});
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Workspace</DialogTitle>
<DialogDescription>Workspace</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="create-title"></Label>
<Input
id="create-title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="请输入标题"
/>
</div>
<div className="space-y-2">
<Label></Label>
<TagInput value={tags} onChange={setTags} placeholder="输入标签后按回车添加" />
</div>
<div className="space-y-2">
<Label htmlFor="create-link"></Label>
<Input
id="create-link"
value={link}
onChange={e => setLink(e.target.value)}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-summary"></Label>
<Input
id="create-summary"
value={summary}
onChange={e => setSummary(e.target.value)}
placeholder="简要描述"
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-description"></Label>
<Textarea
id="create-description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="详细描述..."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={submitting}></Button>
<Button variant="outline" onClick={handleSubmit} disabled={submitting}>
{submitting ? '创建中...' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,117 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { TagInput } from "./TagInput";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function EditDialog({ open, item, onClose, onSubmit }: {
open: boolean;
item: any;
onClose: () => void;
onSubmit: (id: string, data: { title?: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
}) {
const [title, setTitle] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [link, setLink] = useState('');
const [summary, setSummary] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open && item) {
setTitle(item.title || '');
setTags(item.tags || []);
setLink(item.link || '');
setSummary(item.summary || '');
setDescription(item.description || '');
}
}, [open, item]);
const handleSubmit = async () => {
if (!title.trim()) {
alert('请输入标题');
return;
}
if (!item?.id) return;
setSubmitting(true);
try {
await onSubmit(item.id, {
title: title.trim(),
tags,
link: link.trim(),
summary: summary.trim(),
description: description.trim(),
});
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg!">
<DialogHeader>
<DialogTitle>Workspace</DialogTitle>
<DialogDescription>Workspace信息</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-title"></Label>
<Input
id="edit-title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="请输入标题"
/>
</div>
<div className="space-y-2">
<Label></Label>
<TagInput value={tags} onChange={setTags} placeholder="输入标签后按回车添加" />
</div>
<div className="space-y-2">
<Label htmlFor="edit-link"></Label>
<Input
id="edit-link"
value={link}
onChange={e => setLink(e.target.value)}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-summary"></Label>
<Input
id="edit-summary"
value={summary}
onChange={e => setSummary(e.target.value)}
placeholder="简要描述"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description"></Label>
<Textarea
id="edit-description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="详细描述..."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={submitting}></Button>
<Button variant="outline" onClick={handleSubmit} disabled={submitting}>
{submitting ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { XIcon } from "lucide-react";
export function TagInput({ value, onChange, placeholder }: {
value: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
}) {
const [input, setInput] = useState('');
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && input.trim()) {
e.preventDefault();
if (!value.includes(input.trim())) {
onChange([...value, input.trim()]);
}
setInput('');
}
};
const handleRemove = (tag: string) => {
onChange(value.filter(t => t !== tag));
};
return (
<div className="space-y-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder || '输入标签后按回车添加'}
/>
{value.length > 0 && (
<div className="flex flex-wrap gap-1">
{value.map((tag) => (
<Badge key={tag} variant="outline" className="gap-1">
{tag}
<button
type="button"
onClick={() => handleRemove(tag)}
className="hover:text-destructive"
>
<XIcon className="size-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { CreateDialog } from "./CreateDialog";
export { EditDialog } from "./EditDialog";
export { TagInput } from "./TagInput";

View File

@@ -1,29 +1,152 @@
import { useWorkspaceStore, type WorkspaceState } from "./store";
import { useShallow } from "zustand/shallow";
import { useMarkStore, useWorkspaceStore } from "./store";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import dayjs from "dayjs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { CreateDialog, EditDialog } from "./components";
import { SearchIcon, RefreshCwIcon, PlusIcon, PencilIcon, TrashIcon } from "lucide-react";
import { SidebarLayout } from "@/pages/sidebar/components";
export const App = () => {
const markStore = useMarkStore(useShallow(state => {
const workspaceStore = useWorkspaceStore(useShallow((state: WorkspaceState) => {
return {
init: state.init,
list: state.list,
loading: state.loading,
getList: state.getList,
createItem: state.createItem,
updateItem: state.updateItem,
deleteItem: state.deleteItem,
showCreateDialog: state.showCreateDialog,
setShowCreateDialog: state.setShowCreateDialog,
showEditDialog: state.showEditDialog,
setShowEditDialog: state.setShowEditDialog,
editingItem: state.editingItem,
setEditingItem: state.setEditingItem,
}
}));
const workspaceStore = useWorkspaceStore(useShallow(state => {
return {
edit: state.edit,
setEdit: state.setEdit,
}
}));
const [search, setSearch] = useState('');
useEffect(() => {
// @ts-ignore
markStore.init('cnb');
}, []);
console.log('markStore.list', markStore.list);
workspaceStore.getList({ search });
}, [search]);
const handleDelete = async (id: string) => {
if (confirm('确定要删除这个workspace吗')) {
await workspaceStore.deleteItem(id);
}
};
const handleRefresh = () => {
workspaceStore.getList({ search });
};
const handleEdit = (item: any) => {
workspaceStore.setEditingItem(item);
workspaceStore.setShowEditDialog(true);
};
const handleCreate = () => {
workspaceStore.setShowCreateDialog(true);
};
return (
<div>
<h1>Workspaces</h1>
<p>This is the workspaces page.</p>
<SidebarLayout>
<div className="p-5">
<div className="flex justify-between items-center mb-5">
<h1 className="text-2xl font-semibold">Workspaces</h1>
<div className="flex gap-2">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="搜索workspace..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 w-48"
/>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCwIcon className="size-4" />
</Button>
<Button variant="outline" size="sm" onClick={handleCreate}>
<PlusIcon className="size-4" />
Workspace
</Button>
</div>
</div>
{workspaceStore.loading ? (
<div className="text-center py-10 text-muted-foreground">...</div>
) : workspaceStore.list.length === 0 ? (
<div className="text-center py-10 text-muted-foreground">workspace数据</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{workspaceStore.list.map((item) => (
<Card key={item.id}>
<CardHeader>
<CardTitle>{item.title || '未命名'}</CardTitle>
<CardDescription className="text-xs">ID: {item.id}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{item.tags.map((tag, index) => (
<Badge key={index} variant="outline">{tag}</Badge>
))}
</div>
)}
{item.summary && (
<p className="text-sm text-muted-foreground">{item.summary}</p>
)}
{item.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{item.description}</p>
)}
<p className="text-xs text-muted-foreground">
: {item.createdAt ? dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
</p>
<p className="text-xs text-muted-foreground">
: {item.updatedAt ? dayjs(item.updatedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
</p>
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => handleEdit(item)}>
<PencilIcon className="size-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={() => handleDelete(item.id)}>
<TrashIcon className="size-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<CreateDialog
open={workspaceStore.showCreateDialog}
onClose={() => workspaceStore.setShowCreateDialog(false)}
onSubmit={workspaceStore.createItem}
/>
<EditDialog
open={workspaceStore.showEditDialog}
item={workspaceStore.editingItem}
onClose={() => {
workspaceStore.setShowEditDialog(false);
workspaceStore.setEditingItem(null);
}}
onSubmit={workspaceStore.updateItem}
/>
</div>
</SidebarLayout>
);
};

View File

@@ -2,13 +2,155 @@ import { useMarkStore } from '@kevisual/api/store-mark';
export { useMarkStore }
import { create } from 'zustand';
import { queryApi } from '@/modules/mark-api';
import { toast } from 'sonner';
type WorkspaceItem = {
id: string;
title: string;
tags?: string[];
link?: string;
summary?: string;
description?: string;
createdAt: string;
updatedAt: string;
}
type WorkspaceState = {
edit: boolean;
setEdit: (edit: boolean) => void;
list: WorkspaceItem[];
loading: boolean;
setLoading: (loading: boolean) => void;
// 弹窗状态
showCreateDialog: boolean;
setShowCreateDialog: (show: boolean) => void;
showEditDialog: boolean;
setShowEditDialog: (show: boolean) => void;
editingItem: WorkspaceItem | null;
setEditingItem: (item: WorkspaceItem | null) => void;
// 数据操作
getList: (params?: { search?: string, page?: number, pageSize?: number }) => Promise<void>;
createItem: (data: { title: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
updateItem: (id: string, data: { title?: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
deleteItem: (id: string) => Promise<void>;
getItem: (id: string) => Promise<WorkspaceItem | null>;
}
export const useWorkspaceStore = create<WorkspaceState>((set) => ({
export type { WorkspaceState, WorkspaceItem };
export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
edit: false,
setEdit: (edit) => set({ edit }),
list: [],
loading: false,
setLoading: (loading) => set({ loading }),
showCreateDialog: false,
setShowCreateDialog: (show) => set({ showCreateDialog: show }),
showEditDialog: false,
setShowEditDialog: (show) => set({ showEditDialog: show }),
editingItem: null,
setEditingItem: (item) => set({ editingItem: item }),
getList: async (params = {}) => {
const { page = 1, pageSize = 20, search } = params;
set({ loading: true });
try {
const res = await queryApi.mark.list({
markType: 'cnb',
page,
pageSize,
search,
sort: 'DESC'
});
if (res.code === 200) {
set({ list: res.data?.list || [] });
} else {
toast.error(res.message || '获取列表失败');
}
} catch (e) {
console.error('获取workspace列表失败', e);
toast.error('获取列表失败');
} finally {
set({ loading: false });
}
},
createItem: async (data) => {
try {
const res = await queryApi.mark.create({
title: data.title,
markType: 'cnb',
tags: data.tags || [],
link: data.link || '',
summary: data.summary || '',
description: data.description || ''
});
if (res.code === 200) {
toast.success('创建成功');
get().getList();
set({ showCreateDialog: false });
} else {
toast.error(res.message || '创建失败');
}
} catch (e) {
console.error('创建失败', e);
toast.error('创建失败');
}
},
updateItem: async (id, data) => {
try {
const res = await queryApi.mark.update({
id,
data: {
title: data.title || '',
tags: data.tags || [],
link: data.link || '',
summary: data.summary || '',
description: data.description || ''
}
});
if (res.code === 200) {
toast.success('更新成功');
get().getList();
set({ showEditDialog: false, editingItem: null });
} else {
toast.error(res.message || '更新失败');
}
} catch (e) {
console.error('更新失败', e);
toast.error('更新失败');
}
},
deleteItem: async (id) => {
try {
const res = await queryApi.mark.delete({ id });
if (res.code === 200) {
toast.success('删除成功');
get().getList();
} else {
toast.error(res.message || '删除失败');
}
} catch (e) {
console.error('删除失败', e);
toast.error('删除失败');
}
},
getItem: async (id) => {
try {
const res = await queryApi.mark.get({ id });
if (res.code === 200) {
return res.data;
} else {
toast.error(res.message || '获取详情失败');
return null;
}
} catch (e) {
console.error('获取详情失败', e);
return null;
}
}
}));