feat: enhance repository management UI and functionality

- Added navigation to repository details in RepoCard component.
- Implemented a new BuildConfig component for managing build configurations.
- Integrated build configuration initialization and saving logic in the store.
- Updated RepoInfoCard to include workspace management features.
- Improved repository editing dialog with better state handling.
- Enhanced repository list fetching with search capabilities.
- Added support for creating and managing development configurations.
- Refactored code for better readability and maintainability.
This commit is contained in:
2026-02-26 01:11:45 +08:00
parent 5a769a6748
commit a54597c65e
10 changed files with 785 additions and 65 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "@kevisual/cnb-center",
"private": true,
"version": "0.0.5",
"version": "0.0.6",
"type": "module",
"basename": "/root/cnb-center",
"scripts": {
@@ -9,7 +9,7 @@
"build": "vite build",
"preview": "vite preview",
"ui": "pnpm dlx shadcn@latest add ",
"pub": "envision deploy ./dist -k cnb-center -v 0.0.5 -y y -u"
"pub": "envision deploy ./dist -k cnb-center -v 0.0.6 -y y -u"
},
"files": [
"dist"
@@ -28,6 +28,7 @@
"@kevisual/kv-login": "^0.1.15",
"@kevisual/router": "0.0.80",
"@tanstack/react-router": "^1.161.1",
"@uiw/react-codemirror": "^4.25.5",
"ai": "^6.0.91",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -49,6 +50,7 @@
"access": "public"
},
"devDependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@kevisual/gitea": "^0.0.6",
"@kevisual/query": "0.0.49",
"@kevisual/types": "^0.0.12",

206
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
'@tanstack/react-router':
specifier: ^1.161.1
version: 1.162.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@uiw/react-codemirror':
specifier: ^4.25.5
version: 4.25.5(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.15)(codemirror@6.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ai:
specifier: ^6.0.91
version: 6.0.97(zod@4.3.6)
@@ -90,6 +93,9 @@ importers:
specifier: ^5.0.11
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
devDependencies:
'@codemirror/lang-yaml':
specifier: ^6.1.2
version: 6.1.2
'@kevisual/gitea':
specifier: ^0.0.6
version: 0.0.6
@@ -295,6 +301,33 @@ packages:
'@types/react':
optional: true
'@codemirror/autocomplete@6.20.0':
resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
'@codemirror/commands@6.10.2':
resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
'@codemirror/lang-yaml@6.1.2':
resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
'@codemirror/language@6.12.2':
resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==}
'@codemirror/lint@6.9.4':
resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==}
'@codemirror/search@6.6.0':
resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
'@codemirror/state@6.5.4':
resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.39.15':
resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -545,6 +578,21 @@ packages:
resolution: {integrity: sha512-jLsL80wBBKkrJZrfk3SQpJ9JA/zREdlUROj7eCkmzqduAWKSI0wVcXuCKf+mLFCHB0Q0Tkh2rgzjSlurt3JQgw==}
engines: {node: '>=10.0.0'}
'@lezer/common@1.5.1':
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/lr@1.4.8':
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
'@lezer/yaml@1.0.4':
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
@@ -856,6 +904,28 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@uiw/codemirror-extensions-basic-setup@4.25.5':
resolution: {integrity: sha512-2KWS4NqrS9SQzlPs/3sxFhuArvjB3JF6WpsrZqBtGHM5/smCNTULX3lUGeRH+f3mkfMt0k6DR+q0xCW9k+Up5w==}
peerDependencies:
'@codemirror/autocomplete': '>=6.0.0'
'@codemirror/commands': '>=6.0.0'
'@codemirror/language': '>=6.0.0'
'@codemirror/lint': '>=6.0.0'
'@codemirror/search': '>=6.0.0'
'@codemirror/state': '>=6.0.0'
'@codemirror/view': '>=6.0.0'
'@uiw/react-codemirror@4.25.5':
resolution: {integrity: sha512-WUMBGwfstufdbnaiMzQzmOf+6Mzf0IbiOoleexC9ItWcDTJybidLtEi20aP2N58Wn/AQxsd5Otebydaimh7Opw==}
peerDependencies:
'@babel/runtime': '>=7.11.0'
'@codemirror/state': '>=6.0.0'
'@codemirror/theme-one-dark': '>=6.0.0'
'@codemirror/view': '>=6.0.0'
codemirror: '>=6.0.0'
react: '>=17.0.0'
react-dom: '>=17.0.0'
'@vercel/oidc@3.1.0':
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
engines: {node: '>= 20'}
@@ -931,6 +1001,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -940,6 +1013,9 @@ packages:
cookie-es@2.0.0:
resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
crossws@0.3.5:
resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==}
@@ -1333,6 +1409,9 @@ packages:
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
@@ -1505,6 +1584,9 @@ packages:
yaml:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
@@ -1721,6 +1803,69 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@codemirror/autocomplete@6.20.0':
dependencies:
'@codemirror/language': 6.12.2
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
'@lezer/common': 1.5.1
'@codemirror/commands@6.10.2':
dependencies:
'@codemirror/language': 6.12.2
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
'@lezer/common': 1.5.1
'@codemirror/lang-yaml@6.1.2':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.2
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/yaml': 1.0.4
'@codemirror/language@6.12.2':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
style-mod: 4.1.3
'@codemirror/lint@6.9.4':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
crelt: 1.0.6
'@codemirror/search@6.6.0':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
crelt: 1.0.6
'@codemirror/state@6.5.4':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.12.2
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
'@lezer/highlight': 1.2.3
'@codemirror/view@6.39.15':
dependencies:
'@codemirror/state': 6.5.4
crelt: 1.0.6
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -2002,6 +2147,24 @@ snapshots:
'@kevisual/ws@8.19.0': {}
'@lezer/common@1.5.1': {}
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.1
'@lezer/lr@1.4.8':
dependencies:
'@lezer/common': 1.5.1
'@lezer/yaml@1.0.4':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@marijn/find-cluster-break@1.0.2': {}
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
'@emnapi/core': 1.8.1
@@ -2278,6 +2441,33 @@ snapshots:
dependencies:
csstype: 3.2.3
'@uiw/codemirror-extensions-basic-setup@4.25.5(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.10.2
'@codemirror/language': 6.12.2
'@codemirror/lint': 6.9.4
'@codemirror/search': 6.6.0
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
'@uiw/react-codemirror@4.25.5(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.15)(codemirror@6.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@codemirror/commands': 6.10.2
'@codemirror/state': 6.5.4
'@codemirror/theme-one-dark': 6.1.3
'@codemirror/view': 6.39.15
'@uiw/codemirror-extensions-basic-setup': 4.25.5(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
codemirror: 6.0.2
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
transitivePeerDependencies:
- '@codemirror/autocomplete'
- '@codemirror/language'
- '@codemirror/lint'
- '@codemirror/search'
'@vercel/oidc@3.1.0': {}
'@vitejs/plugin-react@5.1.4(vite@8.0.0-beta.15(@types/node@25.3.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0))':
@@ -2364,12 +2554,24 @@ snapshots:
clsx@2.1.1: {}
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.10.2
'@codemirror/language': 6.12.2
'@codemirror/lint': 6.9.4
'@codemirror/search': 6.6.0
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
convert-source-map@2.0.0: {}
cookie-es@1.2.2: {}
cookie-es@2.0.0: {}
crelt@1.0.6: {}
crossws@0.3.5:
dependencies:
uncrypto: 0.1.3
@@ -2690,6 +2892,8 @@ snapshots:
spark-md5@3.0.2: {}
style-mod@4.1.3: {}
tabbable@6.4.0: {}
tailwind-merge@3.5.0: {}
@@ -2775,6 +2979,8 @@ snapshots:
jiti: 2.6.1
tsx: 4.21.0
w3c-keyname@2.2.8: {}
webpack-virtual-modules@0.6.2: {}
yallist@3.1.1: {}

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from "react";
import { useRepoStore } from "../store";
import { useShallow } from "zustand/shallow";
import { toast } from "sonner";
import CodeMirror from "@uiw/react-codemirror";
import { yaml } from "@codemirror/lang-yaml";
import { useLayoutStore } from "@/pages/auth/store";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Workflow } from "lucide-react";
export const BuildConfig = () => {
const repoStore = useRepoStore(useShallow((state) => ({
getItem: state.getItem,
editRepo: state.editRepo,
buildConfig: state.buildConfig,
setBuildConfig: state.setBuildConfig,
initBuildConfig: state.initBuildConfig,
deleteBuildConfig: state.deleteBuildConfig,
loading: state.loading,
buildWorkspace: state.buildWorkspace,
})));
const repo = repoStore.editRepo!;
const me = useLayoutStore((state) => state.me);
const [localConfig, setLocalConfig] = useState(repoStore.buildConfig?.config || "");
// 同步 buildConfig 变化时的状态
useEffect(() => {
setLocalConfig(repoStore.buildConfig?.config || "");
}, [repoStore.buildConfig]);
useEffect(() => {
if (repo) {
repoStore.initBuildConfig({ repo: repo, user: me });
}
}, [repo, me])
const handleSave = () => {
if (repoStore.buildConfig) {
repoStore.setBuildConfig({
...repoStore.buildConfig,
config: localConfig,
}, true);
}
};
const handleFieldChange = (field: string, value: string | null) => {
if (repoStore.buildConfig && value !== null) {
repoStore.setBuildConfig({
...repoStore.buildConfig,
[field]: value,
}, false);
}
};
if (repoStore.loading) {
return <div>Loading...</div>
}
return (
<div className="flex gap-4 h-full overflow-hidden">
{/* 左侧边栏 - 配置信息 */}
<div className="w-64 shrink-0 space-y-4">
<div className="text-xl font-bold border-b pb-2 mb-4 flex">
<span className="text-lg font-semibold"></span>
<button
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"
title="构建工作空间"
>
<Workflow className="w-4 h-4" />
</button>
</div>
<div className="space-y-2">
<label className="text-sm text-neutral-500"></label>
<Input
value={repoStore.buildConfig?.repo || ""}
onChange={(e) => handleFieldChange("repo", e.target.value)}
placeholder="仓库名称"
/>
</div>
<div className="space-y-2">
<label className="text-sm text-neutral-500"></label>
<Input
value={repoStore.buildConfig?.branch || ""}
onChange={(e) => handleFieldChange("branch", e.target.value)}
placeholder="分支名称"
/>
</div>
<div className="space-y-2">
<label className="text-sm text-neutral-500"></label>
<Input
value={repoStore.buildConfig?.event || ""}
onChange={(e) => handleFieldChange("event", e.target.value)}
placeholder="事件名称"
/>
</div>
</div>
{/* 右侧 - 编辑器 */}
<div className="flex-1 flex flex-col h-full ">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium"></span>
<div className="flex gap-2">
<button
onClick={handleSave}
className="px-3 cursor-pointer py-1 text-sm bg-primary text-white rounded hover:bg-primary/90"
>
</button>
<button
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"
>
</button>
</div>
</div>
<div className="border rounded-md flex-1 h-[calc(100%-40px)] overflow-auto scrollbar">
<CodeMirror
value={localConfig}
height="100%"
extensions={[yaml()]}
onChange={(value) => setLocalConfig(value)}
theme="light"
/>
</div>
</div>
</div>
)
}
export default BuildConfig;

View File

@@ -13,13 +13,14 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Star, GitFork, FileText, Edit, FolderGit2, MoreVertical, FileText as IssueIcon, Settings, Play, Trash2, RefreshCw, BookOpen, Copy, Clock, Info, Eye, Square } from 'lucide-react'
import { Star, GitFork, FileText, Edit, FolderGit2, MoreVertical, FileText as IssueIcon, Settings, Play, Trash2, RefreshCw, BookOpen, Copy, Clock, Info, Eye, Square, LinkIcon, ExternalLink } from 'lucide-react'
import { useRepoStore } from '../store'
import { useMemo, useState } from 'react'
import { useShallow } from 'zustand/shallow'
import { myOrgs } from '../store/build'
import { app, cnb } from '@/agents/app'
import { toast } from 'sonner'
import { useNavigate } from '@tanstack/react-router'
interface RepoCardProps {
repo: any
@@ -46,13 +47,13 @@ export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings,
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 () => {
const res = await app.run({ path: 'cnb', key: 'build-knowledge-base', payload: { repo: repo.path } })
if (res.code === 200) {
toast.success("知识库创建中")
getList(true)
getList({}, true)
}
}
const onClone = async () => {
@@ -72,6 +73,14 @@ export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings,
<div className="p-6 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="text-lg font-bold text-neutral-900 hover:text-neutral-600 transition-colors line-clamp-1 group-hover:underline"
onClick={() => {
navigate({ to: `/repo?repo=${repo.path}` })
}}
>
{repo.path}
</div>
{isKnowledge && (
<TooltipProvider>
<Tooltip>
@@ -88,14 +97,6 @@ export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings,
</Tooltip>
</TooltipProvider>
)}
<a
href={repo.web_url}
target="_blank"
rel="noopener noreferrer"
className="text-lg font-bold text-neutral-900 hover:text-neutral-600 transition-colors line-clamp-1 group-hover:underline"
>
{repo.path}
</a>
{isWorkspaceActive && (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
@@ -195,13 +196,19 @@ export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings,
<Copy className="w-4 h-4 mr-2" />
Clone URL
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
window.open(repo.web_url, '_blank')
}} className="cursor-pointer">
<ExternalLink className="w-4 h-4 mr-2" />
访
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onIssue(repo)} className="cursor-pointer">
<IssueIcon className="w-4 h-4 mr-2" />
Issue
访
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSettings(repo)} className="cursor-pointer">
<Settings className="w-4 h-4 mr-2" />
访
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
@@ -268,22 +275,25 @@ export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings,
</div>
{repo.site && (
<a
href={repo.site}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-neutral-500 hover:text-neutral-900 hover:underline block truncate transition-colors"
<div
className="text-xs text-neutral-500 hover:text-neutral-900 hover:underline flex transition-colors"
onClick={() => {
window.open(repo.site, '_blank')
}}
>
🔗 {repo.site}
</a>
<LinkIcon className="w-4 h-4 shrink-0 mr-2" />
<div className='truncate grow'>
{repo.site}
</div>
</div>
)}
{repo.description && (
<p className="text-sm text-neutral-600 line-clamp-2 min-h-10">
<p className="ml-2 text-sm text-neutral-600 line-clamp-2 min-h-10 grow">
{repo.description}
</p>
)}
<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">
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
<Star className="w-3.5 h-3.5" />

View File

@@ -1,16 +1,35 @@
import { useNavigate } from "@tanstack/react-router";
import { useMemo } from "react";
import { useRepoStore } from "../store";
import { useShallow } from "zustand/shallow";
import { toast } from "sonner";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Star, GitFork, FileText, ExternalLink, Calendar, User, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Star, GitFork, FileText, ExternalLink, Calendar, User, Copy, ArrowLeft, Play, Square, Eye, BookOpen, RefreshCw } from "lucide-react";
import { myOrgs } from "../store/build";
export const RepoInfoCard = () => {
const repoStore = useRepoStore(useShallow((state) => ({
getItem: state.getItem,
const navigate = useNavigate();
const { workspaceList, getWorkspaceDetail, stopWorkspace, editRepo, setSelectedSyncRepo, setSyncDialogOpen } = useRepoStore(useShallow((state) => ({
workspaceList: state.workspaceList,
getWorkspaceDetail: state.getWorkspaceDetail,
stopWorkspace: state.stopWorkspace,
editRepo: state.editRepo,
setSelectedSyncRepo: state.setSelectedSyncRepo,
setSyncDialogOpen: state.setSyncDialogOpen,
})));
const repo = repoStore.editRepo!;
const repo = editRepo!;
const workspace = useMemo(() => {
return workspaceList.find(ws => ws.slug === repo.path)
}, [workspaceList, repo.path])
const isWorkspaceActive = !!workspace
const owner = repo.path.split('/')[0]
const isMine = myOrgs.includes(owner)
const isKnowledge = repo?.flags === "KnowledgeBase"
const onClone = () => {
const url = `git clone https://cnb.cool/${repo.path}`
navigator.clipboard.writeText(url).then(() => {
@@ -30,6 +49,13 @@ export const RepoInfoCard = () => {
{/* 标题行 */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<button
onClick={() => navigate({ to: '/' })}
className="cursor-pointer flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 transition-colors"
>
<ArrowLeft className="w-4 h-4 text-neutral-600" />
</button>
<span className="text-sm text-neutral-500 font-mono">
{repo.path}
</span>
@@ -43,37 +69,147 @@ export const RepoInfoCard = () => {
<Badge variant="outline" className="shrink-0">
{repo.visibility_level === 'Public' ? '公开' : repo.visibility_level === 'Private' ? '私有' : repo.visibility_level}
</Badge>
{isKnowledge && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<div className="shrink-0">
<BookOpen className="w-5 h-5 text-neutral-700" />
</div>
}
/>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isWorkspaceActive && (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isWorkspaceActive && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
size="sm"
variant="outline"
onClick={() => {
stopWorkspace(workspace)
}}
className="h-8 w-8 p-0 border-neutral-200 hover:border-red-600 hover:bg-red-600 hover:text-white transition-all cursor-pointer"
>
<Square className="w-4 h-4" />
</Button>
}
/>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
size="sm"
variant="outline"
onClick={() => {
if (!isWorkspaceActive) {
// TODO: 启动工作区
} else {
getWorkspaceDetail(workspace)
}
}}
className="h-8 w-8 p-0 border-neutral-200 hover:border-neutral-900 hover:bg-neutral-900 hover:text-white transition-all cursor-pointer"
>
{isWorkspaceActive ? <Eye className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</Button>
}
/>
<TooltipContent>
<p>{isWorkspaceActive ? '查看工作区' : '启动工作区'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<a
href={repo.web_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-neutral-600 hover:text-neutral-900 transition-colors shrink-0"
>
<ExternalLink className="w-4 h-4" />
CNB
</a>
</div>
<a
href={repo.web_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-neutral-600 hover:text-neutral-900 transition-colors shrink-0"
>
<ExternalLink className="w-4 h-4" />
CNB
</a>
</div>
{/* 描述 */}
{repo.description && (
<p className="text-sm text-neutral-600 max-h-[4.5em] overflow-hidden truncate">
<p className="text-sm text-neutral-600 h-12 overflow-hidden truncate">
{repo.description}
</p>
)}
{/* 主题标签 */}
{repo.topics && (
<div className="flex flex-wrap gap-2">
{repo.topics.split(',').map((topic: string, idx: number) => (
<Badge key={idx} variant="outline" className="text-xs border-neutral-300 text-neutral-700">
{topic.trim()}
</Badge>
))}
</div>
)}
{/* 主题标签和知识库 */}
<div className="flex items-center gap-2">
{/* 主题标签 */}
{repo.topics && (
<div className="flex flex-wrap gap-2">
{repo.topics.split(',').map((topic: string, idx: number) => (
<Badge key={idx} variant="outline" className="text-xs border-neutral-300 text-neutral-700">
{topic.trim()}
</Badge>
))}
</div>
)}
{/* 语言和更新时间 */}
</div>
{/* 统计信息 */}
<div className="flex items-center gap-6 text-xs text-neutral-500">
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
<Star className="w-3.5 h-3.5" />
<span className="font-medium">{repo.star_count}</span>
</span>
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
<GitFork className="w-3.5 h-3.5" />
<span className="font-medium">{repo.fork_count}</span>
</span>
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
<FileText className="w-3.5 h-3.5" />
<span className="font-medium">{repo.open_issue_count}</span>
</span>
{isWorkspaceActive && (
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors cursor-pointer">
<Play className="w-3.5 h-3.5" />
<span className="font-medium"></span>
</span>
)}
{isMine && (
<span
className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors cursor-pointer"
onClick={() => {
setSelectedSyncRepo(repo)
setSyncDialogOpen(true)
}}
>
<RefreshCw className="w-3.5 h-3.5" />
<span className="font-medium"></span>
</span>
)}
</div>
{/* 更新信息 */}
<div className="flex items-center gap-6 text-xs text-neutral-500">
{repo.last_update_nickname && (
<span className="flex items-center gap-1">

View File

@@ -59,7 +59,7 @@ export function EditRepoDialog({ open, onOpenChange, repo }: EditRepoDialogProps
const onSubmit = async (data: FormData) => {
if (!repo) return
await updateRepoInfo({
path: repo.path,
description: data.description?.trim() || '',
@@ -67,8 +67,8 @@ export function EditRepoDialog({ open, onOpenChange, repo }: EditRepoDialogProps
topics: tags.join(','),
license: data.license?.trim() || '',
})
await getList(true)
await getList({}, true)
onOpenChange(false)
}

View File

@@ -165,6 +165,21 @@ export const App = () => {
</div>
</footer>
<CommonRepoDialog />
</div>
)
}
export const CommonRepoDialog = () => {
const { editRepo, showEditDialog, setShowEditDialog, showCreateDialog, setShowCreateDialog, } = useRepoStore(useShallow((state) => ({
editRepo: state.editRepo,
showEditDialog: state.showEditDialog,
setShowEditDialog: state.setShowEditDialog,
showCreateDialog: state.showCreateDialog,
setShowCreateDialog: state.setShowCreateDialog,
})))
return (
<>
<EditRepoDialog
open={showEditDialog}
onOpenChange={setShowEditDialog}
@@ -176,8 +191,7 @@ export const App = () => {
/>
<WorkspaceDetailDialog />
<SyncRepoDialog />
</div>
</>
)
}
export default App;

View File

@@ -1,18 +1,27 @@
import { useSearch } from "@tanstack/react-router";
import { useRepoStore } from "../store";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useShallow } from "zustand/shallow";
import { RepoInfoCard } from "../components/RepoInfoCard";
import BuildConfig from "../components/BuildConfig";
import { CommonRepoDialog } from "../page";
export const App = () => {
const params = useSearch({ strict: false }) as { repo?: string };
const repoStore = useRepoStore(useShallow((state) => ({
getItem: state.getItem,
editRepo: state.editRepo,
refresH: state.refresh,
})));
const [activeTab, setActiveTab] = useState("build");
const tabs = [
{ key: "build", label: "构建配置" },
{ key: "info", label: "基本信息" },
]
useEffect(() => {
if (params.repo) {
repoStore.getItem(params.repo);
repoStore.refresH({ search: params.repo, showTips: false });
} else {
console.log('no repo param')
}
@@ -21,10 +30,30 @@ export const App = () => {
return <div>Loading...</div>
}
return (
<div className="p-2">
<div className="p-2 flex-col flex gap-2 h-full">
<div className="px-4">
<RepoInfoCard />
</div>
<div className="px-4 h-[calc(100%-200px)] scrollbar flex-col flex gap-4 overflow-hidden">
<div className="flex border-b mb-4">
{tabs.map(tab => (
<div
key={tab.key}
className={`px-4 py-2 cursor-pointer ${activeTab === tab.key ? 'border-b-2 border-gray-500' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</div>
))}
</div>
{activeTab === 'build' && <BuildConfig />}
{activeTab === 'info' && (
<div className="p-4 border rounded bg-white h-full overflow-auto scrollbar">
<pre className="whitespace-pre-wrap break-all">{JSON.stringify(repoStore.editRepo, null, 2)}</pre>
</div>
)}
</div>
<CommonRepoDialog />
</div>
)
}

View File

@@ -49,4 +49,58 @@ export const createCommitBlankConfig = (params: { repo?: string, event: 'api_tri
git commit --allow-empty -m "up: ${now}"
git push
`
}
export const createDevConfig = (params: { repo?: string, event?: string }) => {
const event = params?.event || 'api_trigger_event';
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
# 可选应用配置
# FEISHU_APP_ID: '' # 飞书应用 ID
# FEISHU_APP_SECRET: '' # 飞书应用密钥
# CNB_COOKIE: '' # 可选配置用cnb.cool的cookie
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 # 仓库名(默认即可)
##### 配置结束 #####
main:
${event}:
- docker:
image: docker.cnb.cool/kevisual/dev-env:latest
services:
- vscode
- docker
runner:
cpus: 16
imports:
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
env: !reference [.common_env, env]
stages:
- name: 环境变量
script: printenv > ~/.env.development
- name: 启动nginx
script: nginx
- name: 初始化开发机
script: zsh /workspace/scripts/init.sh
# endStages:
# - name: 结束阶段
# script: bun /workspace/scripts/end.ts
`
}

View File

@@ -3,7 +3,8 @@ import { query } from '@/modules/query';
import { toast } from 'sonner';
import { cnb } from '@/agents/app'
import { WorkspaceInfo } from '@kevisual/cnb'
import { createBuildConfig, createCommitBlankConfig } from './build';
import { createBuildConfig, createCommitBlankConfig, createDevConfig } from './build';
import { useLayoutStore } from '@/pages/auth/store';
interface DisplayModule {
activity: boolean;
contributors: boolean;
@@ -52,6 +53,12 @@ interface Data {
type WorkspaceTabType = 'dev' | 'work'
type BuildConfig = {
repo: string;
branch: string;
event: string;
config: string;
}
type State = {
formData: Record<string, any>;
setFormData: (data: Record<string, any>) => void;
@@ -68,13 +75,13 @@ type State = {
setShowEditDialog: (show: boolean) => void;
showCreateDialog: boolean;
setShowCreateDialog: (show: boolean) => void;
getList: (silent?: boolean) => Promise<any>;
getList: (params?: { search?: string }, silent?: boolean) => Promise<any>;
updateRepoInfo: (data: Partial<Data>) => Promise<any>;
createRepo: (data: { visibility: any, path: string, description: string, license: string }) => Promise<any>;
deleteItem: (repo: string) => Promise<any>;
workspaceList: WorkspaceInfo[];
getWorkspaceList: () => Promise<any>;
refresh: (opts?: { message?: string, showTips?: boolean }) => Promise<any>;
refresh: (opts?: { message?: string, showTips?: boolean, search?: string }) => Promise<any>;
startWorkspace: (data: Partial<Data>, params?: { open?: boolean, branch?: string }) => Promise<any>;
stopWorkspace: (workspace?: WorkspaceInfo) => Promise<any>;
getWorkspaceDetail: (data: WorkspaceInfo) => Promise<any>;
@@ -89,6 +96,11 @@ type State = {
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;
setBuildConfig: (config: BuildConfig | null, save?: boolean) => Promise<any>;
deleteBuildConfig: (params: { repo: Data, user?: any }) => Promise<any>;
initBuildConfig: (params: { repo: Data, user?: any }) => Promise<any>;
buildWorkspace: () => Promise<any>;
}
export const useRepoStore = create<State>((set, get) => {
@@ -114,6 +126,127 @@ export const useRepoStore = create<State>((set, get) => {
setSyncDialogOpen: (open) => set({ syncDialogOpen: open }),
selectedSyncRepo: null,
setSelectedSyncRepo: (repo) => set({ selectedSyncRepo: repo }),
buildConfig: null,
setBuildConfig: async (config, save = true) => {
const me = useLayoutStore.getState().me;
if (config && config!.repo && save) {
let path: string = config.repo || '';
path = path.replace(/\//g, '__');
const key = `buildConfig_${path}.json`;
if (config && me) {
const res = await query.post({
path: 'config',
key: 'update',
data: {
key: key,
data: config,
}
})
if (res.code === 200) {
toast.success('配置已保存')
} else {
toast.error(res.message || '配置保存失败')
}
} else if (config) {
localStorage.setItem(key, JSON.stringify(config));
}
}
set({ buildConfig: config })
},
deleteBuildConfig: async (params: { repo: Data, user?: any }) => {
const repo = params.repo;
let path: string = repo.path || '';
path = path.replace(/\//g, '__');
const key = `buildConfig_${path}.json`;
if (params?.user) {
const res = await query.post({
path: 'config',
key: 'delete',
data: {
key: key,
}
})
if (res.code === 200) {
toast.success('配置已删除')
} else {
toast.error(res.message || '配置删除失败')
}
} else {
localStorage.removeItem(key);
toast.success('配置已删除')
}
},
initBuildConfig: async (params) => {
const repo = params.repo;
if (!repo) {
toast.error('仓库数据异常');
return;
}
set({ loading: true })
try {
console.log('初始化构建配置', params)
let path: string = repo.path || '';
path = path.replace(/\//g, '__');
const key = `buildConfig_${path}.json`;
if (params?.user) {
const res = await query.post({
path: 'config',
key: 'get',
data: {
key: key,
}
})
if (res.code === 200 && res.data?.data) {
set({ buildConfig: res.data.data })
return;
}
} else {
const localConfig = localStorage.getItem(key);
if (localConfig) {
try {
const config = JSON.parse(localConfig);
set({ buildConfig: config })
return;
} catch (e) {
console.error('本地配置解析失败', e);
}
}
}
const config: BuildConfig = {
repo: repo.path,
branch: 'main',
event: 'api_trigger_event',
config: createDevConfig({ repo: repo.path, event: 'api_trigger_event' }),
}
set({ buildConfig: config })
} catch (e) {
toast.error('配置加载失败');
console.error('配置加载失败', e);
}
finally {
set({ loading: false })
}
},
buildWorkspace: async () => {
const config = get().buildConfig;
if (!config) {
toast.error('请先保存构建配置');
return;
}
const res = await cnb.build.startBuild(config.repo, {
branch: config.branch,
env: {},
event: config.event,
config: config.config,
})
if (res.code === 200) {
toast.success('构建已触发')
} else {
toast.error(res.message || '构建触发失败')
}
},
getItem: async (repo: string) => {
const { setLoading } = get();
setLoading(true);
@@ -129,13 +262,19 @@ export const useRepoStore = create<State>((set, get) => {
setLoading(false);
}
},
getList: async (silent = false) => {
getList: async (params?: { search?: string }, silent = false) => {
const { setLoading } = get();
if (!silent) {
setLoading(true);
}
try {
const res = await cnb.repo.getRepoList({})
let opts = {}
if (params?.search) {
opts = {
search: params.search
}
}
const res = await cnb.repo.getRepoList(opts)
if (res.code === 200) {
const list = res.data! || []
set({ list });
@@ -171,8 +310,8 @@ export const useRepoStore = create<State>((set, get) => {
toast.error(res.message || '更新失败');
}
},
refresh: async (opts?: { message?: string, showTips?: boolean }) => {
const getList = get().getList();
refresh: async (opts?: { message?: string, showTips?: boolean, search?: string }) => {
const getList = get().getList({ search: opts?.search }, true);
const getWorkspaceList = get().getWorkspaceList();
await Promise.all([getList, getWorkspaceList]);
if (opts?.showTips !== false) {
@@ -207,7 +346,7 @@ export const useRepoStore = create<State>((set, get) => {
if (res.code === 200) {
toast.success('删除成功');
// 刷新列表
await get().getList(true);
await get().getList({}, true);
} else {
toast.error(res.message || '删除失败');
}
@@ -216,7 +355,7 @@ export const useRepoStore = create<State>((set, get) => {
if (e.message?.includes('JSON') || e.message?.includes('json')) {
toast.success('删除成功');
// 刷新列表
await get().getList(true);
await get().getList({}, true);
} else {
toast.error('删除失败');
console.error('删除错误:', e);