feat: 添加工作区链接功能,更新状态管理以支持获取和显示工作区的秘密链接

This commit is contained in:
xiongxiao
2026-03-10 14:28:50 +08:00
committed by cnb
parent 4ed81a1c68
commit f74d5a4510
2 changed files with 144 additions and 42 deletions

View File

@@ -1,10 +1,15 @@
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider
} from '@/components/ui/tooltip'
import { useRepoStore } from '../store' import { useRepoStore } from '../store'
import type { WorkspaceOpen } from '../store' import type { WorkspaceOpen } from '../store'
import { import {
@@ -19,11 +24,13 @@ import {
Zap, Zap,
Copy, Copy,
Check, Check,
Square Square,
Link
} from 'lucide-react' } from 'lucide-react'
import { useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useShallow } from 'zustand/shallow' import { useShallow } from 'zustand/shallow'
import clsx from 'clsx'
type LinkItemKey = keyof WorkspaceOpen; type LinkItemKey = keyof WorkspaceOpen;
interface LinkItem { interface LinkItem {
@@ -35,7 +42,6 @@ interface LinkItem {
} }
const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode; url?: string }) => { const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode; url?: string }) => {
const [isHovered, setIsHovered] = useState(false)
const [isCopied, setIsCopied] = useState(false) const [isCopied, setIsCopied] = useState(false)
const handleClick = () => { const handleClick = () => {
@@ -43,7 +49,7 @@ const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode;
copy() copy()
return; return;
} }
if (url) { if (url && url.startsWith('http')) {
window.open(url, '_blank') window.open(url, '_blank')
} }
} }
@@ -57,41 +63,38 @@ const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode;
toast.error('复制失败') toast.error('复制失败')
} }
} }
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation()
if (!url) return
copy()
}
return ( return (
<button <TooltipProvider delay={200}>
<Tooltip>
<TooltipTrigger>
<div
onClick={handleClick} onClick={handleClick}
disabled={!url}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="relative flex items-center gap-3 p-3 rounded-lg border border-neutral-200 hover:border-neutral-900 hover:bg-neutral-50 transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-neutral-200 disabled:hover:bg-transparent group" className="relative flex items-center gap-3 p-3 rounded-lg border border-neutral-200 hover:border-neutral-900 hover:bg-neutral-50 transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-neutral-200 disabled:hover:bg-transparent group"
> >
<div className="w-8 h-8 flex items-center justify-center text-neutral-700"> <div className="w-8 h-8 flex items-center justify-center text-neutral-700">
{icon} {icon}
</div> </div>
<span className="text-sm font-medium text-neutral-900 flex-1 text-left truncate">{label}</span> <span className="text-sm font-medium text-neutral-900 flex-1 text-left truncate">{label}</span>
{url && isHovered && ( {url && (
<div <div
onClick={handleCopy} onClick={(e) => {
role="button" e.stopPropagation()
tabIndex={0} copy()
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleCopy(e as any)
}
}} }}
role="button"
className="w-6 h-6 flex items-center justify-center text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 rounded transition-colors cursor-pointer" className="w-6 h-6 flex items-center justify-center text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 rounded transition-colors cursor-pointer"
> >
{isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} {isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</div> </div>
)} )}
</button> </div>
</TooltipTrigger>
<TooltipContent>
<p>{url}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) )
} }
@@ -124,6 +127,34 @@ const DevTabContent = ({ linkItems, workspaceLink, stopWorkspace }: {
) )
} }
// Link tab 内容(暂留空)
const LinkTabContent = () => {
const store = useRepoStore(useShallow((state) => ({
selectWorkspace: state.selectWorkspace,
workspaceSecretLink: state.workspaceSecretLink,
})))
const links = store.workspaceSecretLink.map(item => ({
label: item.title,
url: item.value
}))
if (links.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-neutral-400">
,
</div>
)
}
return (
<div className="flex flex-col items-center justify-center py-8 text-neutral-400">
<div className="grid grid-cols-1 gap-3 w-full max-w-sm">
{links.map(link => (
<LinkItem key={link.label} label={link.label} icon={<Link className="w-5 h-5" />} url={link.url} />
))}
</div>
</div>
)
}
// Work tab 内容(暂留,需要根据 business_id 做事情) // Work tab 内容(暂留,需要根据 business_id 做事情)
const WorkTabContent = () => { const WorkTabContent = () => {
const store = useRepoStore(useShallow((state) => ({ const store = useRepoStore(useShallow((state) => ({
@@ -178,7 +209,7 @@ const WorkTabContent = () => {
} }
export function WorkspaceDetailDialog() { export function WorkspaceDetailDialog() {
const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink, stopWorkspace, workspaceTab, setWorkspaceTab, selectWorkspace } = useRepoStore(useShallow((state) => ({ const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink, stopWorkspace, workspaceTab, setWorkspaceTab, getWorkspaceSecretLink, selectWorkspace, workspaceSecretLink } = useRepoStore(useShallow((state) => ({
showWorkspaceDialog: state.showWorkspaceDialog, showWorkspaceDialog: state.showWorkspaceDialog,
setShowWorkspaceDialog: state.setShowWorkspaceDialog, setShowWorkspaceDialog: state.setShowWorkspaceDialog,
workspaceLink: state.workspaceLink, workspaceLink: state.workspaceLink,
@@ -186,6 +217,8 @@ export function WorkspaceDetailDialog() {
workspaceTab: state.workspaceTab, workspaceTab: state.workspaceTab,
setWorkspaceTab: state.setWorkspaceTab, setWorkspaceTab: state.setWorkspaceTab,
selectWorkspace: state.selectWorkspace, selectWorkspace: state.selectWorkspace,
getWorkspaceSecretLink: state.getWorkspaceSecretLink,
workspaceSecretLink: state.workspaceSecretLink
}))) })))
const linkItems: LinkItem[] = [ const linkItems: LinkItem[] = [
{ {
@@ -252,6 +285,11 @@ export function WorkspaceDetailDialog() {
getUrl: (data) => data.codebuddycn getUrl: (data) => data.codebuddycn
}, },
].sort((a, b) => (a.order || 0) - (b.order || 0)) ].sort((a, b) => (a.order || 0) - (b.order || 0))
useEffect(() => {
if (selectWorkspace) {
getWorkspaceSecretLink(selectWorkspace)
}
}, [selectWorkspace])
return ( return (
<Dialog open={showWorkspaceDialog} onOpenChange={setShowWorkspaceDialog}> <Dialog open={showWorkspaceDialog} onOpenChange={setShowWorkspaceDialog}>
<DialogContent className="max-w-md! bg-white"> <DialogContent className="max-w-md! bg-white">
@@ -284,12 +322,29 @@ export function WorkspaceDetailDialog() {
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" /> <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
)} )}
</button> </button>
<button
onClick={() => setWorkspaceTab('link')}
className={clsx(`cursor-pointer flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${workspaceTab === 'link'
? 'text-neutral-900'
: 'text-neutral-500 hover:text-neutral-700'
}`)}
>
<Link className="w-4 h-4 inline-block mr-1" />
Link
{workspaceTab === 'link' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
)}
</button>
</div> </div>
{/* Tab 内容 */} {/* Tab 内容 */}
<div className="py-2"> <div className="py-2">
{workspaceTab === 'dev' ? ( {workspaceTab === 'dev' && (
<DevTabContent linkItems={linkItems} workspaceLink={workspaceLink} stopWorkspace={stopWorkspace} /> <DevTabContent linkItems={linkItems} workspaceLink={workspaceLink} stopWorkspace={stopWorkspace} />
) : ( )}
{workspaceTab === 'link' && (
<LinkTabContent />
)}
{workspaceTab === 'work' && (
<WorkTabContent /> <WorkTabContent />
)} )}
</div> </div>

View File

@@ -5,6 +5,7 @@ import { queryApi as cnbApi } from '@/modules/cnb-api'
import { WorkspaceInfo } from '@kevisual/cnb' import { WorkspaceInfo } from '@kevisual/cnb'
import { createBuildConfig, createCommitBlankConfig, createDevConfig } from './build'; import { createBuildConfig, createCommitBlankConfig, createDevConfig } from './build';
import { useLayoutStore } from '@/pages/auth/store'; import { useLayoutStore } from '@/pages/auth/store';
import { Query } from '@kevisual/query';
interface DisplayModule { interface DisplayModule {
activity: boolean; activity: boolean;
contributors: boolean; contributors: boolean;
@@ -51,7 +52,7 @@ interface Data {
pinned_time: string; pinned_time: string;
} }
type WorkspaceTabType = 'dev' | 'work' type WorkspaceTabType = 'dev' | 'work' | 'link'
type BuildConfig = { type BuildConfig = {
repo: string; repo: string;
@@ -101,6 +102,8 @@ type State = {
deleteBuildConfig: (params: { repo: Data, user?: any }) => Promise<any>; deleteBuildConfig: (params: { repo: Data, user?: any }) => Promise<any>;
initBuildConfig: (params: { repo: Data, user?: any }) => Promise<any>; initBuildConfig: (params: { repo: Data, user?: any }) => Promise<any>;
buildWorkspace: () => Promise<any>; buildWorkspace: () => Promise<any>;
workspaceSecretLink: { title: string, key: string, value?: string }[];
getWorkspaceSecretLink: (workspace: WorkspaceInfo) => Promise<any>;
} }
export const useRepoStore = create<State>((set, get) => { export const useRepoStore = create<State>((set, get) => {
@@ -248,6 +251,50 @@ export const useRepoStore = create<State>((set, get) => {
toast.error(res.message || '构建触发失败') toast.error(res.message || '构建触发失败')
} }
}, },
workspaceSecretLink: [],
getWorkspaceSecretLink: async (workspace) => {
console.log('获取工作区链接', workspace)
const business_id = workspace?.business_id;
const baseURL = `https://${business_id}-51515.cnb.run/client/router`;
console.log('工作区链接', baseURL)
const url = new URL(baseURL);
const token = localStorage.getItem('token');
// const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZC1rZXktMSJ9.eyJzdWIiOiJ1c2VyOjBlNzAwZGM4LTkwZGQtNDFiNy05MWRkLTMzNmVhNTFkZTNkMiIsIm5hbWUiOiJyb290IiwiZXhwIjoxNzczMTI4NTMwLCJpc3MiOiJodHRwczovL2NvbnZleC5rZXZpc3VhbC5jbiIsImlhdCI6MTc3MzEyMTMzMCwiYXVkIjoiY29udmV4LWFwcCJ9.g4kANiPc352QFBfa0yb4gl98mLHTruL_3HvIaKYwN1Qy3-P8QV6X_WhqgMOskQphNGsBFC-LRmZq2808GnqwpjDTE0ekXbsO4L9C-D6F3mBMwowqpvmURCRVg6Ys6LSkzw4sM75VbHpfFX3ZQVtZymvAWhxxxvjhdKGPdrdw5bNymTbCw-Y9NrYW6u2mExLrvrfXl3vJqaCz7obj_mR-G_2PB3g5KPQYhWCl8--TkYOS9fiNIYlcacnO36bZXhHheHFZEr_gb8UG5ECg0ND8hsH8TijiYBAY6T93nhGrZG7E0oQY3xXsVm-mkvXP2tLCXwKH7SFmH4M0tdZLRqLqKw'
url.searchParams.set('path', 'cnb_board');
url.searchParams.set('key', 'live');
const res = await fetch(url.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
}
}).then(res => res.json());
const labelData: { title: string, key: string, value?: string }[] = [
{
title: 'Opencode Secret',
key: 'opencodeUrlSecret',
},
{
title: 'Openclaw Secret',
key: 'openclawUrlSecret',
},
{
title: 'StartTime',
key: 'buildStartTime',
}
];
if (res.code === 200) {
const list = res.data?.list || [];
const workspaceSecretLink: { title: string, key: string, value?: string }[] = [];
labelData.forEach(item => {
const find = list.find((l: any) => l.key === item.key);
if (find) {
workspaceSecretLink.push({ ...item, value: find.value });
}
})
set({ workspaceSecretLink })
}
},
getItem: async (repo: string) => { getItem: async (repo: string) => {
const { setLoading } = get(); const { setLoading } = get();
setLoading(true); setLoading(true);