generated from kevisual/vite-react-template
feat: 添加仓库管理页面和 AI 功能,优化路由和导航
- 新增仓库列表页面,支持查看和管理 CNB 仓库 - 添加 AI 代理系统和状态管理 - 新增 tags-input、popover、textarea、tooltip 等 UI 组件 - 更新依赖:@kevisual/cnb 升级至 0.0.22,添加 idb-keyval - 改进路由守卫:未配置 API Key 时自动跳转配置页 - 优化 Dialog 遮罩层样式和整体布局 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
48
AGENTS.md
Normal file
48
AGENTS.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
本指南为在此仓库中工作的 AI 编码代理提供关键信息。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ui/ # shadcn/ui 组件(Base UI 基础组件)
|
||||||
|
├── lib/ # 工具函数(cn() 函数用于 className 合并)
|
||||||
|
├── modules/ # 应用模块(query client、basename)
|
||||||
|
├── pages/ # 页面组件(默认导出)
|
||||||
|
├── routes/ # TanStack Router 基于文件的路由
|
||||||
|
├── styles/ # 全局样式、主题 CSS
|
||||||
|
└── main.tsx # 应用入口
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 代码风格指南
|
||||||
|
|
||||||
|
### 模块目录结构
|
||||||
|
|
||||||
|
每个新模块(如 `page-app`)应遵循以下结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
pages/page-app/
|
||||||
|
├── components/ # 模块专属组件
|
||||||
|
├── store/ # 模块状态管理
|
||||||
|
└── module/ # 模块功能函数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 状态和数据获取
|
||||||
|
|
||||||
|
- **Zustand** 用于全局状态管理
|
||||||
|
- **@kevisual/query** 用于数据获取(QueryClient 实例位于 `src/modules/query.ts`)
|
||||||
|
- **React Hook Form** 用于表单管理
|
||||||
|
|
||||||
|
## 核心依赖
|
||||||
|
|
||||||
|
- **@base-ui/react**: Headless UI 基础组件
|
||||||
|
- **@tanstack/react-router**: 基于 TanStack Router 插件的文件路由
|
||||||
|
- **class-variance-authority**: 基于变体的样式系统
|
||||||
|
- **clsx + tailwind-merge**: 通过 `cn()` 提供 className 工具函数
|
||||||
|
- **lucide-react**: 图标库
|
||||||
|
- **react-hook-form**: 表单处理
|
||||||
|
- **sonner**: Toast 通知
|
||||||
|
- **zustand**: 状态管理
|
||||||
|
- **tailwindcss v4**: 使用 @tailwindcss/vite 插件进行样式处理
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "vite-react",
|
"name": "@kevisual/cnb-center",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"@ai-sdk/openai": "^3.0.26",
|
"@ai-sdk/openai": "^3.0.26",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.28",
|
"@ai-sdk/openai-compatible": "^2.0.28",
|
||||||
"@base-ui/react": "^1.1.0",
|
"@base-ui/react": "^1.1.0",
|
||||||
"@kevisual/cnb": "^0.0.20",
|
"@kevisual/cnb": "^0.0.22",
|
||||||
"@kevisual/context": "^0.0.4",
|
"@kevisual/context": "^0.0.4",
|
||||||
"@kevisual/router": "0.0.70",
|
"@kevisual/router": "0.0.70",
|
||||||
"@tanstack/react-router": "^1.158.4",
|
"@tanstack/react-router": "^1.158.4",
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"es-toolkit": "^1.44.0",
|
"es-toolkit": "^1.44.0",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -21,8 +21,8 @@ importers:
|
|||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.1.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@kevisual/cnb':
|
'@kevisual/cnb':
|
||||||
specifier: ^0.0.20
|
specifier: ^0.0.22
|
||||||
version: 0.0.20(dotenv@17.2.4)(idb-keyval@6.2.1)
|
version: 0.0.22(dotenv@17.2.4)(idb-keyval@6.2.2)
|
||||||
'@kevisual/context':
|
'@kevisual/context':
|
||||||
specifier: ^0.0.4
|
specifier: ^0.0.4
|
||||||
version: 0.0.4
|
version: 0.0.4
|
||||||
@@ -47,6 +47,9 @@ importers:
|
|||||||
es-toolkit:
|
es-toolkit:
|
||||||
specifier: ^1.44.0
|
specifier: ^1.44.0
|
||||||
version: 1.44.0
|
version: 1.44.0
|
||||||
|
idb-keyval:
|
||||||
|
specifier: ^6.2.2
|
||||||
|
version: 6.2.2
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.563.0
|
specifier: ^0.563.0
|
||||||
version: 0.563.0(react@19.2.4)
|
version: 0.563.0(react@19.2.4)
|
||||||
@@ -638,8 +641,8 @@ packages:
|
|||||||
'@kevisual/cache@0.0.2':
|
'@kevisual/cache@0.0.2':
|
||||||
resolution: {integrity: sha512-2Cl5KF2Gi27uLfhO6CdTMFnRzx9vYnqevAo7d9ab3rOaqTgF8tLeAXglXyRbaWW3WUbHU2XaOb4r98uUsqIQQw==}
|
resolution: {integrity: sha512-2Cl5KF2Gi27uLfhO6CdTMFnRzx9vYnqevAo7d9ab3rOaqTgF8tLeAXglXyRbaWW3WUbHU2XaOb4r98uUsqIQQw==}
|
||||||
|
|
||||||
'@kevisual/cnb@0.0.20':
|
'@kevisual/cnb@0.0.22':
|
||||||
resolution: {integrity: sha512-3ODGAT8vEnU90X/6SUeqMK1ZJCcvyn44bMsC7Joz0kvDKhntstbf/nZIm5TRhngvPEcOPyc+KROchTweC/qcNA==}
|
resolution: {integrity: sha512-KX8oSmmaHnT4qqCfAoQoHZbkcohUVSK7LfdsEKTlItrE77rPyZcvD+APByroxH4FMQ80ItRW9tQlxBO8iRlrIw==}
|
||||||
|
|
||||||
'@kevisual/context@0.0.4':
|
'@kevisual/context@0.0.4':
|
||||||
resolution: {integrity: sha512-HJeLeZQLU+7tCluSfOyvkgKLs0HjCZrdJlZgEgKRSa8XTwZfMAUt6J7qZTbrZAHBlPtX68EPu/PI8JMCeu3WAQ==}
|
resolution: {integrity: sha512-HJeLeZQLU+7tCluSfOyvkgKLs0HjCZrdJlZgEgKRSa8XTwZfMAUt6J7qZTbrZAHBlPtX68EPu/PI8JMCeu3WAQ==}
|
||||||
@@ -1490,6 +1493,9 @@ packages:
|
|||||||
idb-keyval@6.2.1:
|
idb-keyval@6.2.1:
|
||||||
resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==}
|
resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==}
|
||||||
|
|
||||||
|
idb-keyval@6.2.2:
|
||||||
|
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
|
||||||
|
|
||||||
immer@10.1.1:
|
immer@10.1.1:
|
||||||
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
|
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
|
||||||
|
|
||||||
@@ -2730,14 +2736,14 @@ snapshots:
|
|||||||
- tslib
|
- tslib
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
'@kevisual/cnb@0.0.20(dotenv@17.2.4)(idb-keyval@6.2.1)':
|
'@kevisual/cnb@0.0.22(dotenv@17.2.4)(idb-keyval@6.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@kevisual/query': 0.0.40
|
'@kevisual/query': 0.0.40
|
||||||
'@kevisual/router': 0.0.70
|
'@kevisual/router': 0.0.70
|
||||||
'@kevisual/use-config': 1.0.30(dotenv@17.2.4)
|
'@kevisual/use-config': 1.0.30(dotenv@17.2.4)
|
||||||
es-toolkit: 1.44.0
|
es-toolkit: 1.44.0
|
||||||
nanoid: 5.1.6
|
nanoid: 5.1.6
|
||||||
unstorage: 1.17.4(idb-keyval@6.2.1)
|
unstorage: 1.17.4(idb-keyval@6.2.2)
|
||||||
ws: '@kevisual/ws@8.19.0'
|
ws: '@kevisual/ws@8.19.0'
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -3532,6 +3538,8 @@ snapshots:
|
|||||||
|
|
||||||
idb-keyval@6.2.1: {}
|
idb-keyval@6.2.1: {}
|
||||||
|
|
||||||
|
idb-keyval@6.2.2: {}
|
||||||
|
|
||||||
immer@10.1.1:
|
immer@10.1.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3996,7 +4004,7 @@ snapshots:
|
|||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
webpack-virtual-modules: 0.6.2
|
webpack-virtual-modules: 0.6.2
|
||||||
|
|
||||||
unstorage@1.17.4(idb-keyval@6.2.1):
|
unstorage@1.17.4(idb-keyval@6.2.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
chokidar: 5.0.0
|
chokidar: 5.0.0
|
||||||
@@ -4007,7 +4015,7 @@ snapshots:
|
|||||||
ofetch: 1.5.1
|
ofetch: 1.5.1
|
||||||
ufo: 1.6.3
|
ufo: 1.6.3
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
idb-keyval: 6.2.1
|
idb-keyval: 6.2.2
|
||||||
|
|
||||||
update-browserslist-db@1.1.1(browserslist@4.24.2):
|
update-browserslist-db@1.1.1(browserslist@4.24.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
21
src/agents/app.ts
Normal file
21
src/agents/app.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { QueryRouterServer } from '@kevisual/router/browser'
|
||||||
|
|
||||||
|
import { useContextKey } from '@kevisual/context'
|
||||||
|
import { useConfigStore } from '@/app/config/store'
|
||||||
|
import { CNB } from '@kevisual/cnb'
|
||||||
|
|
||||||
|
export const app = useContextKey('router', 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'
|
||||||
|
}
|
||||||
|
return new CNB({
|
||||||
|
token: config.CNB_API_KEY,
|
||||||
|
cookie: config.CNB_COOKIE,
|
||||||
|
cors
|
||||||
|
})
|
||||||
|
})
|
||||||
43
src/app/ai/page.tsx
Normal file
43
src/app/ai/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { CNB, Issue } from '@kevisual/cnb'
|
||||||
|
import { useLayoutEffect } from 'react'
|
||||||
|
import { useConfigStore } from '../config/store'
|
||||||
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||||
|
import { generateText } from 'ai';
|
||||||
|
const init2 = async () => {
|
||||||
|
const cnb = new CNB({
|
||||||
|
token: 'cIDfLOOIr1Trt15cdnwfndupEZG',
|
||||||
|
cookie: 'CNBSESSION=1770014410.1935321989751226368.7f386c282d80efb5256180ef94c2865e20a8be72e2927a5f8eb1eb72142de39f;csrfkey=2028873452',
|
||||||
|
cors: {
|
||||||
|
baseUrl: 'https://cors.kevisual.cn'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// const res = await cnb.issue.getList('kevisual/kevisual')
|
||||||
|
// console.log('res', res)
|
||||||
|
const token = await cnb.user.getCurrentUser()
|
||||||
|
console.log('token', token)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initAi = async () => {
|
||||||
|
const state = useConfigStore.getState()
|
||||||
|
const config = state.config
|
||||||
|
const cors = state.config.CNB_CORS_URL
|
||||||
|
const base = cors + '/' + config.AI_BASE_URL.replace('https://', '')
|
||||||
|
const cnb = createOpenAICompatible({
|
||||||
|
baseURL: base,
|
||||||
|
name: 'custom-cnb',
|
||||||
|
apiKey: config.AI_API_KEY,
|
||||||
|
});
|
||||||
|
const model = config.AI_MODEL;
|
||||||
|
// const model = 'hunyuan';
|
||||||
|
const { text } = await generateText({
|
||||||
|
model: cnb(model),
|
||||||
|
prompt: '你好',
|
||||||
|
});
|
||||||
|
console.log('text', text)
|
||||||
|
}
|
||||||
|
export const Home = () => {
|
||||||
|
useLayoutEffect(() => { initAi() }, [])
|
||||||
|
return <div>Home Page</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home;
|
||||||
@@ -5,6 +5,7 @@ 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 { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { configSchema } from './store/schema';
|
import { configSchema } from './store/schema';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const ConfigPage = () => {
|
export const ConfigPage = () => {
|
||||||
const { config, setConfig, resetConfig } = useConfigStore();
|
const { config, setConfig, resetConfig } = useConfigStore();
|
||||||
@@ -13,8 +14,10 @@ export const ConfigPage = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const result = configSchema.safeParse(config);
|
const result = configSchema.safeParse(config);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log('配置已保存:', config);
|
toast.success('配置已保存')
|
||||||
// 可以在此处添加 toast 通知
|
setTimeout(() => {
|
||||||
|
location.reload()
|
||||||
|
}, 400)
|
||||||
} else {
|
} else {
|
||||||
console.error('验证错误:', result.error.format());
|
console.error('验证错误:', result.error.format());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,3 @@
|
|||||||
import { CNB, Issue } from '@kevisual/cnb'
|
import App from './repo/page'
|
||||||
import { useLayoutEffect } from 'react'
|
|
||||||
import { useConfigStore } from './config/store'
|
|
||||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
||||||
import { generateText } from 'ai';
|
|
||||||
const init2 = async () => {
|
|
||||||
const cnb = new CNB({
|
|
||||||
token: 'cIDfLOOIr1Trt15cdnwfndupEZG',
|
|
||||||
cookie: 'CNBSESSION=1770014410.1935321989751226368.7f386c282d80efb5256180ef94c2865e20a8be72e2927a5f8eb1eb72142de39f;csrfkey=2028873452',
|
|
||||||
cors: {
|
|
||||||
baseUrl: 'https://cors.kevisual.cn'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// const res = await cnb.issue.getList('kevisual/kevisual')
|
|
||||||
// console.log('res', res)
|
|
||||||
const token = await cnb.user.getCurrentUser()
|
|
||||||
console.log('token', token)
|
|
||||||
}
|
|
||||||
|
|
||||||
const initAi = async () => {
|
export default App;
|
||||||
const state = useConfigStore.getState()
|
|
||||||
const config = state.config
|
|
||||||
const cors = state.config.CNB_CORS_URL
|
|
||||||
const base = cors + '/' + config.AI_BASE_URL.replace('https://', '')
|
|
||||||
const cnb = createOpenAICompatible({
|
|
||||||
baseURL: base,
|
|
||||||
name: 'custom-cnb',
|
|
||||||
apiKey: config.AI_API_KEY,
|
|
||||||
});
|
|
||||||
const model = config.AI_MODEL;
|
|
||||||
// const model = 'hunyuan';
|
|
||||||
const { text } = await generateText({
|
|
||||||
model: cnb(model),
|
|
||||||
prompt: '你好',
|
|
||||||
});
|
|
||||||
console.log('text', text)
|
|
||||||
}
|
|
||||||
export const Home = () => {
|
|
||||||
useLayoutEffect(() => { initAi() }, [])
|
|
||||||
return <div>Home Page</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Home;
|
|
||||||
217
src/app/repo/components/RepoCard.tsx
Normal file
217
src/app/repo/components/RepoCard.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
import { Star, GitFork, FileText, Edit, FolderGit2, MoreVertical, FileText as IssueIcon, Settings, Play, Trash2, RefreshCw } from 'lucide-react'
|
||||||
|
import { useRepoStore } from '../store'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { myOrgs } from '../store/build'
|
||||||
|
|
||||||
|
interface RepoCardProps {
|
||||||
|
repo: any
|
||||||
|
onStartWorkspace: (repo: any) => void
|
||||||
|
onEdit: (repo: any) => void
|
||||||
|
onIssue: (repo: any) => void
|
||||||
|
onSettings: (repo: any) => void
|
||||||
|
onDelete: (repo: any) => void
|
||||||
|
onSync?: (repo: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings, onDelete, onSync }: RepoCardProps) {
|
||||||
|
const [deletePopoverOpen, setDeletePopoverOpen] = useState(false)
|
||||||
|
const { workspaceList, getWorkspaceDetail } = useRepoStore();
|
||||||
|
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)
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
<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">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onStartWorkspace(repo)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>启动工作区</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(repo)} className="cursor-pointer">
|
||||||
|
<Edit 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) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDeletePopoverOpen(true)
|
||||||
|
}}
|
||||||
|
className="cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
删除
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Popover open={deletePopoverOpen} onOpenChange={setDeletePopoverOpen}>
|
||||||
|
<PopoverTrigger >
|
||||||
|
<div style={{ display: 'none' }} />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">确认删除</h4>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
确定要删除仓库 <span className="font-semibold text-neutral-900">{repo.path}</span> 吗?此操作无法撤销。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeletePopoverOpen(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="bg-red-600 text-white border-red-600 hover:bg-red-700 hover:border-red-700"
|
||||||
|
onClick={() => {
|
||||||
|
onDelete(repo)
|
||||||
|
setDeletePopoverOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 hover:bg-neutral-100 transition-colors">
|
||||||
|
{topic.trim()}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
🔗 {repo.site}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{repo.description && (
|
||||||
|
<p className="text-sm text-neutral-600 line-clamp-2 min-h-[2.5rem]">
|
||||||
|
{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" />
|
||||||
|
<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"
|
||||||
|
onClick={() => {
|
||||||
|
getWorkspaceDetail(workspace)
|
||||||
|
}}>
|
||||||
|
<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={() => onSync?.(repo)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
<span className="font-medium">同步</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
src/app/repo/modules/EditRepoDialog.tsx
Normal file
140
src/app/repo/modules/EditRepoDialog.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
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 { TagsInput } from '@/components/tags-input'
|
||||||
|
import { useRepoStore } from '../store'
|
||||||
|
|
||||||
|
interface EditRepoDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
repo: {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
description: string
|
||||||
|
site: string
|
||||||
|
topics: string
|
||||||
|
license: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
description: string
|
||||||
|
site: string
|
||||||
|
topics: string
|
||||||
|
license: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditRepoDialog({ open, onOpenChange, repo }: EditRepoDialogProps) {
|
||||||
|
const { updateRepoInfo, getList } = useRepoStore()
|
||||||
|
const { register, handleSubmit, reset, setValue } = useForm<FormData>()
|
||||||
|
const [tags, setTags] = useState<string[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (repo) {
|
||||||
|
const topicsArray = repo.topics ? repo.topics.split(',').map(t => t.trim()).filter(Boolean) : []
|
||||||
|
setTags(topicsArray)
|
||||||
|
reset({
|
||||||
|
description: repo.description || '',
|
||||||
|
site: repo.site || '',
|
||||||
|
topics: repo.topics || '',
|
||||||
|
license: repo.license || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [repo, reset])
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
if (!repo) return
|
||||||
|
|
||||||
|
await updateRepoInfo({
|
||||||
|
path: repo.path,
|
||||||
|
description: data.description?.trim() || '',
|
||||||
|
site: data.site?.trim() || '',
|
||||||
|
topics: tags.join(','),
|
||||||
|
license: data.license?.trim() || '',
|
||||||
|
})
|
||||||
|
|
||||||
|
await getList(true)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repo) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl!">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑仓库信息</DialogTitle>
|
||||||
|
<DialogDescription>{repo.path}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">描述</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
{...register('description')}
|
||||||
|
placeholder="输入仓库描述"
|
||||||
|
className="w-full min-h-[100px]"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="site">网站</Label>
|
||||||
|
<Input
|
||||||
|
id="site"
|
||||||
|
{...register('site')}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
type="url"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="topics">标签</Label>
|
||||||
|
<TagsInput
|
||||||
|
value={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
placeholder="输入标签后按 Enter 或逗号添加"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">按 Enter 或逗号添加标签,点击 × 删除</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="license">许可证</Label>
|
||||||
|
<Input
|
||||||
|
id="license"
|
||||||
|
{...register('license')}
|
||||||
|
placeholder="MIT, Apache-2.0, GPL-3.0 等"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
src/app/repo/modules/SyncRepoDialog.tsx
Normal file
85
src/app/repo/modules/SyncRepoDialog.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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 { get, set } from 'idb-keyval'
|
||||||
|
|
||||||
|
const SYNC_REPO_STORAGE_KEY = 'sync-repo-mapping'
|
||||||
|
|
||||||
|
export function SyncRepoDialog() {
|
||||||
|
const { syncDialogOpen, setSyncDialogOpen, selectedSyncRepo, buildSync } = useRepoStore()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<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={handleSync}
|
||||||
|
disabled={!toRepo.trim()}
|
||||||
|
>
|
||||||
|
开始同步
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
185
src/app/repo/modules/WorkspaceDetailDialog.tsx
Normal file
185
src/app/repo/modules/WorkspaceDetailDialog.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { useRepoStore } from '../store'
|
||||||
|
import type { WorkspaceOpen } from '../store'
|
||||||
|
import {
|
||||||
|
Code2,
|
||||||
|
Terminal,
|
||||||
|
Sparkles,
|
||||||
|
MousePointer2,
|
||||||
|
Box,
|
||||||
|
Lock,
|
||||||
|
Radio,
|
||||||
|
Bot,
|
||||||
|
Zap,
|
||||||
|
Copy,
|
||||||
|
Check
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
type LinkItemKey = keyof WorkspaceOpen;
|
||||||
|
interface LinkItem {
|
||||||
|
key: LinkItemKey
|
||||||
|
label: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
order?: number
|
||||||
|
getUrl: (data: Partial<WorkspaceOpen>) => string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode; url?: string }) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (url?.startsWith?.('ssh') || url?.startsWith?.('cnb')) {
|
||||||
|
copy()
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url) {
|
||||||
|
window.open(url, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url!)
|
||||||
|
setIsCopied(true)
|
||||||
|
toast.success('已复制到剪贴板')
|
||||||
|
setTimeout(() => setIsCopied(false), 2000)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleCopy = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!url) return
|
||||||
|
copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 flex items-center justify-center text-neutral-700">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-neutral-900 flex-1 text-left truncate">{label}</span>
|
||||||
|
{url && isHovered && (
|
||||||
|
<div
|
||||||
|
onClick={handleCopy}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleCopy(e as any)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceDetailDialog() {
|
||||||
|
const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink } = useRepoStore()
|
||||||
|
|
||||||
|
const linkItems: LinkItem[] = [
|
||||||
|
{
|
||||||
|
key: 'webide' as LinkItemKey,
|
||||||
|
label: 'Web IDE',
|
||||||
|
icon: <Code2 className="w-5 h-5" />,
|
||||||
|
order: 1,
|
||||||
|
getUrl: (data) => data.webide
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vscode' as LinkItemKey,
|
||||||
|
label: 'VS Code',
|
||||||
|
icon: <Code2 className="w-5 h-5" />,
|
||||||
|
order: 2,
|
||||||
|
getUrl: (data) => data.vscode
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vscode-insiders' as LinkItemKey,
|
||||||
|
label: 'VS Code Insiders',
|
||||||
|
icon: <Sparkles className="w-5 h-5" />,
|
||||||
|
order: 5,
|
||||||
|
getUrl: (data) => data['vscode-insiders']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cursor' as LinkItemKey,
|
||||||
|
label: 'Cursor',
|
||||||
|
icon: <MousePointer2 className="w-5 h-5" />,
|
||||||
|
order: 6,
|
||||||
|
getUrl: (data) => data.cursor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jetbrains' as LinkItemKey,
|
||||||
|
label: 'JetBrains IDEs',
|
||||||
|
icon: <Box className="w-5 h-5" />,
|
||||||
|
order: 7,
|
||||||
|
getUrl: (data) => Object.values(data.jetbrains || {}).find(Boolean)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ssh' as LinkItemKey,
|
||||||
|
label: 'SSH',
|
||||||
|
icon: <Lock className="w-5 h-5" />,
|
||||||
|
order: 4,
|
||||||
|
getUrl: (data) => data.ssh
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'remoteSsh' as LinkItemKey,
|
||||||
|
label: 'Remote SSH',
|
||||||
|
icon: <Radio className="w-5 h-5" />,
|
||||||
|
order: 8,
|
||||||
|
getUrl: (data) => data.remoteSsh
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'codebuddy' as LinkItemKey,
|
||||||
|
label: 'CodeBuddy',
|
||||||
|
icon: <Bot className="w-5 h-5" />,
|
||||||
|
order: 9,
|
||||||
|
getUrl: (data) => data.codebuddy
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'codebuddycn' as LinkItemKey,
|
||||||
|
label: 'CodeBuddy CN',
|
||||||
|
icon: <Zap className="w-5 h-5" />,
|
||||||
|
order: 3,
|
||||||
|
getUrl: (data) => data.codebuddycn
|
||||||
|
},
|
||||||
|
].sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showWorkspaceDialog} onOpenChange={setShowWorkspaceDialog}>
|
||||||
|
<DialogContent className="max-w-md! bg-white">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-neutral-900">打开工作区</DialogTitle>
|
||||||
|
<DialogDescription className="text-neutral-500">选择一个编辑器或方式来打开工作区</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{linkItems.map((item) => (
|
||||||
|
<LinkItem
|
||||||
|
key={item.key}
|
||||||
|
label={item.label}
|
||||||
|
icon={item.icon}
|
||||||
|
url={item.getUrl(workspaceLink)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
93
src/app/repo/page.tsx
Normal file
93
src/app/repo/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useRepoStore } from './store/index'
|
||||||
|
import { RepoCard } from './components/RepoCard'
|
||||||
|
import { EditRepoDialog } from './modules/EditRepoDialog'
|
||||||
|
import { WorkspaceDetailDialog } from './modules/WorkspaceDetailDialog'
|
||||||
|
import { SyncRepoDialog } from './modules/SyncRepoDialog'
|
||||||
|
|
||||||
|
export const App = () => {
|
||||||
|
const { list, getList, loading, editRepo, setEditRepo, showEditDialog, setShowEditDialog, startWorkspace, getWorkspaceList, deleteItem, setSelectedSyncRepo, setSyncDialogOpen } = useRepoStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getList()
|
||||||
|
getWorkspaceList()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleEdit = (repo: any) => {
|
||||||
|
setEditRepo(repo)
|
||||||
|
setShowEditDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIssue = (repo: any) => {
|
||||||
|
window.open(`https://cnb.cool/${repo.path}/-/issues`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSettings = (repo: any) => {
|
||||||
|
window.open(`https://cnb.cool/${repo.path}/-/settings`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (repo: any) => {
|
||||||
|
if (repo.path)
|
||||||
|
deleteItem(repo.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSync = (repo: any) => {
|
||||||
|
setSelectedSyncRepo(repo)
|
||||||
|
setSyncDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-neutral-900 mb-2">仓库列表</h1>
|
||||||
|
<p className="text-neutral-600">共 {list.length} 个仓库</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{list.map((repo) => (
|
||||||
|
<RepoCard
|
||||||
|
key={repo.id}
|
||||||
|
repo={repo}
|
||||||
|
onStartWorkspace={startWorkspace}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onIssue={handleIssue}
|
||||||
|
onSettings={handleSettings}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onSync={handleSync}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{list.length === 0 && !loading && (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<div className="text-neutral-400 text-lg">暂无仓库数据</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="border-t border-neutral-200 bg-white py-6 mt-auto">
|
||||||
|
<div className="container mx-auto px-6 max-w-7xl">
|
||||||
|
<div className="flex items-center justify-between text-sm text-neutral-500">
|
||||||
|
<div>© 2026 仓库管理系统</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a href="#" className="hover:text-neutral-900 transition-colors">关于</a>
|
||||||
|
<a href="#" className="hover:text-neutral-900 transition-colors">帮助</a>
|
||||||
|
<a href="#" className="hover:text-neutral-900 transition-colors">联系我们</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<EditRepoDialog
|
||||||
|
open={showEditDialog}
|
||||||
|
onOpenChange={setShowEditDialog}
|
||||||
|
repo={editRepo}
|
||||||
|
/>
|
||||||
|
<WorkspaceDetailDialog />
|
||||||
|
<SyncRepoDialog />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
33
src/app/repo/store/build.ts
Normal file
33
src/app/repo/store/build.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export const myOrgs = ['kevisual', 'kevision', 'skillpod', 'zxj.im', 'abearxiong']
|
||||||
|
|
||||||
|
export const createBuildConfig = (params: { repo: string }) => {
|
||||||
|
const toRepo = params.repo!;
|
||||||
|
return `
|
||||||
|
# .cnb.yml
|
||||||
|
include:
|
||||||
|
- https://cnb.cool/kevisual/cnb/-/blob/main/.cnb/template.yml
|
||||||
|
|
||||||
|
.common_env: &common_env
|
||||||
|
env:
|
||||||
|
TO_REPO: ${toRepo}
|
||||||
|
TO_URL: git.xiongxiao.me
|
||||||
|
imports:
|
||||||
|
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
|
||||||
|
|
||||||
|
.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:
|
||||||
|
api_trigger_sync_to_gitea:
|
||||||
|
- <<: *common_sync_to_gitea
|
||||||
|
api_trigger_sync_from_gitea:
|
||||||
|
- <<: *common_sync_from_gitea
|
||||||
|
`
|
||||||
|
};
|
||||||
313
src/app/repo/store/index.ts
Normal file
313
src/app/repo/store/index.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { query } from '@/modules/query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { cnb } from '@/agents/app'
|
||||||
|
import { WorkspaceInfo } from '@kevisual/cnb'
|
||||||
|
import { createBuildConfig } from './build';
|
||||||
|
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: (silent?: boolean) => Promise<any>;
|
||||||
|
updateRepoInfo: (data: Partial<Data>) => Promise<any>;
|
||||||
|
deleteItem: (repo: string) => Promise<any>;
|
||||||
|
workspaceList: WorkspaceInfo[];
|
||||||
|
getWorkspaceList: () => Promise<any>;
|
||||||
|
startWorkspace: (data: Partial<Data>, params?: { open?: boolean, branch?: string }) => Promise<any>;
|
||||||
|
getWorkspaceDetail: (data: WorkspaceInfo) => Promise<any>;
|
||||||
|
workspaceLink: Partial<WorkspaceOpen>;
|
||||||
|
showWorkspaceDialog: boolean;
|
||||||
|
setShowWorkspaceDialog: (show: boolean) => void;
|
||||||
|
syncDialogOpen: boolean;
|
||||||
|
setSyncDialogOpen: (open: boolean) => void;
|
||||||
|
selectedSyncRepo: Data | null;
|
||||||
|
setSelectedSyncRepo: (repo: Data | null) => void;
|
||||||
|
buildSync: (data: Partial<Data>, params: { toRepo?: string, fromRepo?: string }) => 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 }),
|
||||||
|
showWorkspaceDialog: false,
|
||||||
|
setShowWorkspaceDialog: (show) => set({ showWorkspaceDialog: show }),
|
||||||
|
syncDialogOpen: false,
|
||||||
|
setSyncDialogOpen: (open) => set({ syncDialogOpen: open }),
|
||||||
|
selectedSyncRepo: null,
|
||||||
|
setSelectedSyncRepo: (repo) => set({ selectedSyncRepo: repo }),
|
||||||
|
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 (silent = false) => {
|
||||||
|
const { setLoading } = get();
|
||||||
|
if (!silent) {
|
||||||
|
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 {
|
||||||
|
if (!silent) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateRepoInfo: async (data) => {
|
||||||
|
const repo = data.path!;
|
||||||
|
const topics = data.topics?.split?.(',');
|
||||||
|
const updateData = {
|
||||||
|
description: data.description,
|
||||||
|
license: data?.license as any,
|
||||||
|
site: data.site,
|
||||||
|
topics: topics,
|
||||||
|
}
|
||||||
|
const res = await cnb.repo.updateRepoInfo(repo, updateData)
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('更新成功');
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '更新失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteItem: async (repo: string) => {
|
||||||
|
try {
|
||||||
|
const res = await cnb.repo.deleteRepoCookie(repo)
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('删除成功');
|
||||||
|
// 刷新列表
|
||||||
|
await get().getList(true);
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// 如果是 JSON 解析错误,说明删除成功但响应为空
|
||||||
|
if (e.message?.includes('JSON') || e.message?.includes('json')) {
|
||||||
|
toast.success('删除成功');
|
||||||
|
// 刷新列表
|
||||||
|
await get().getList(true);
|
||||||
|
} else {
|
||||||
|
toast.error('删除失败');
|
||||||
|
console.error('删除错误:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
workspaceList: [],
|
||||||
|
getWorkspaceList: async () => {
|
||||||
|
const res = await cnb.workspace.list({
|
||||||
|
status: 'running',
|
||||||
|
pageSize: 100
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
const list: WorkspaceInfo[] = res.data?.list as any;
|
||||||
|
set({ workspaceList: list || [] })
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startWorkspace: async (data, params = { open: true, branch: 'main' }) => {
|
||||||
|
const repo = data.path;
|
||||||
|
const checkOpen = async () => {
|
||||||
|
const res = await cnb.workspace.startWorkspace(repo!, {
|
||||||
|
branch: params.branch || 'main'
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
if (!res?.data?.sn) {
|
||||||
|
const url = res.data?.url! || '';
|
||||||
|
if (url.includes('loading')) {
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
data: res.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
data: res.data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
toast.success(`新创建了一个工作区,sn: ${res.data.sn}`)
|
||||||
|
return {
|
||||||
|
code: 300,
|
||||||
|
data: res.data,
|
||||||
|
message: '第一次启动'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
code: res.code,
|
||||||
|
message: res.message || '请求失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await checkOpen()
|
||||||
|
if (res.code === 300) {
|
||||||
|
toast.success(`新创建了一个工作区,sn: ${res.data?.sn}`)
|
||||||
|
if (params.open) {
|
||||||
|
const loadingToastId = toast.loading('正在启动工作区...')
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
const check = await checkOpen()
|
||||||
|
if (check.code === 200 && check.data?.url) {
|
||||||
|
clearInterval(interval)
|
||||||
|
toast.dismiss(loadingToastId)
|
||||||
|
toast.success(`工作区已启动,正在打开: ${check.data.url}`)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.open(check.data?.url, '_blank')
|
||||||
|
}, 200)
|
||||||
|
} else if (check.code === 400) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// 30秒后自动停止检测
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(interval)
|
||||||
|
toast.dismiss(loadingToastId)
|
||||||
|
toast.error('工作区启动超时')
|
||||||
|
}, 3 * 60 * 1000)
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.code === 200 && res.data?.url) {
|
||||||
|
console.log('res', res)
|
||||||
|
toast.success(`工作区已启动,正在打开: ${res.data.url}`)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.open(res.data?.url, '_blank')
|
||||||
|
}, 200)
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '启动失败');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
getWorkspaceDetail: async (workspaceInfo) => {
|
||||||
|
const res = await cnb.workspace.getDetail(workspaceInfo.slug, workspaceInfo.sn) as any;
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({
|
||||||
|
workspaceLink: res.data,
|
||||||
|
showWorkspaceDialog: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
workspaceLink: {},
|
||||||
|
buildSync: async (data, params) => {
|
||||||
|
const repo = data.path!;
|
||||||
|
const toRepo = params.toRepo;
|
||||||
|
const fromRepo = params.fromRepo;
|
||||||
|
if (!toRepo && !fromRepo) {
|
||||||
|
toast.error('请选择同步的目标仓库或来源仓库')
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let event = toRepo ? 'api_trigger_sync_to_gitea' : 'api_trigger_sync_from_gitea';
|
||||||
|
const res = await cnb.build.startBuild(repo, {
|
||||||
|
branch: 'main',
|
||||||
|
env: {},
|
||||||
|
event: event,
|
||||||
|
config: createBuildConfig({ repo: toRepo! || fromRepo! }),
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('同步提交成功')
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '同步提交失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export type WorkspaceOpen = {
|
||||||
|
codebuddy: string;
|
||||||
|
codebuddycn: string;
|
||||||
|
cursor: string;
|
||||||
|
jetbrains: Record<string, string>;
|
||||||
|
jumpUrl: string;
|
||||||
|
remoteSsh: string;
|
||||||
|
ssh: string;
|
||||||
|
vscode: string;
|
||||||
|
'vscode-insiders': string;
|
||||||
|
webide: string;
|
||||||
|
}
|
||||||
|
const openWorkspace = (workspace: WorkspaceInfo, params: { vscode?: boolean, ssh?: boolean }) => {
|
||||||
|
const openVsCode = params?.vscode ?? true;
|
||||||
|
if (openVsCode) {
|
||||||
|
const pipeline_id = workspace.pipeline_id;
|
||||||
|
const url = `vscode://vscode-remote/ssh-remote+${pipeline_id}`
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/app/store/index.ts
Normal file
130
src/app/store/index.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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 || '更新失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
78
src/components/tags-input.tsx
Normal file
78
src/components/tags-input.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { Badge } from "./ui/badge"
|
||||||
|
import { Input } from "./ui/input"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface TagsInputProps {
|
||||||
|
value: string[]
|
||||||
|
onChange: (tags: string[]) => void
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagsInput({ value, onChange, placeholder, className }: TagsInputProps) {
|
||||||
|
const [inputValue, setInputValue] = React.useState("")
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter" || e.key === ",") {
|
||||||
|
e.preventDefault()
|
||||||
|
addTag()
|
||||||
|
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
|
||||||
|
removeTag(value.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTag = () => {
|
||||||
|
const trimmedValue = inputValue.trim()
|
||||||
|
if (trimmedValue && !value.includes(trimmedValue)) {
|
||||||
|
onChange([...value, trimmedValue])
|
||||||
|
setInputValue("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTag = (index: number) => {
|
||||||
|
onChange(value.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap gap-2 rounded-md border border-neutral-200 bg-white px-3 py-3 text-sm ring-offset-white transition-all hover:border-neutral-300 focus-within:ring-2 focus-within:ring-neutral-950 focus-within:ring-offset-2 cursor-text",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
>
|
||||||
|
{value.map((tag, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5 pl-2.5 pr-1.5 py-1 border-neutral-300 text-neutral-700 bg-neutral-50 hover:bg-neutral-100 transition-colors h-7 font-normal"
|
||||||
|
>
|
||||||
|
<span className="text-xs">{tag}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
removeTag(index)
|
||||||
|
}}
|
||||||
|
className="ml-0.5 rounded-full hover:bg-neutral-200 hover:text-neutral-900 p-0.5 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={addTag}
|
||||||
|
placeholder={value.length === 0 ? placeholder : ""}
|
||||||
|
className="flex-1 min-w-[150px] border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-7 placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -30,7 +30,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/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate 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/20 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
88
src/components/ui/popover.tsx
Normal file
88
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
PopoverPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<PopoverPrimitive.Popup
|
||||||
|
data-slot="popover-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 flex flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-72 origin-(--transform-origin) outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Positioner>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Title
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Description
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDescription,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
}
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-3 aria-invalid:ring-3 md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
64
src/components/ui/tooltip.tsx
Normal file
64
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "top",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
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)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{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.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
import { Link, Outlet, createRootRoute } from '@tanstack/react-router'
|
import { Link, Outlet, createRootRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
import { useConfigStore } from '@/app/config/store'
|
||||||
|
import { useEffect } from 'react'
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
useEffect(() => {
|
||||||
|
const config = useConfigStore.getState().config;
|
||||||
|
if (!config.CNB_API_KEY) {
|
||||||
|
navigate({
|
||||||
|
to: '/config'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className='h-full overflow-hidden'>
|
||||||
|
|
||||||
<div className="p-2 flex gap-2 text-lg">
|
<div className="p-2 flex gap-2 text-lg">
|
||||||
<Link
|
<Link
|
||||||
@@ -18,11 +28,17 @@ function RootComponent() {
|
|||||||
}}
|
}}
|
||||||
activeOptions={{ exact: true }}
|
activeOptions={{ exact: true }}
|
||||||
>
|
>
|
||||||
Home
|
仓库列表
|
||||||
|
</Link>
|
||||||
|
<Link to='/config'>
|
||||||
|
配置项
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<Outlet />
|
<main className='h-[calc(100%-4rem)] overflow-auto scrollbar'>
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
<TanStackRouterDevtools position="bottom-right" />
|
<TanStackRouterDevtools position="bottom-right" />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import Home from '@/app/page'
|
import App from '@/app/page'
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
return <Home />
|
return <App />
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user