generated from kevisual/vite-react-template
feat: add repository management dialogs and store functionality
- Implement CreateRepoDialog for creating new repositories with form validation. - Implement EditRepoDialog for editing existing repository details. - Implement SyncRepoDialog for syncing repositories with Gitea, including repository creation if necessary. - Implement WorkspaceDetailDialog for managing workspace links and actions. - Enhance the repo store with new state management for repository actions, including creating, editing, and syncing repositories. - Add build configuration utilities for repository synchronization. - Create a new page for repository management, integrating all dialogs and functionalities. - Add login route for authentication.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@kevisual/cnb-center",
|
"name": "@kevisual/cnb-center",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.4",
|
"version": "0.0.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"basename": "/root/cnb-center",
|
"basename": "/root/cnb-center",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"ui": "pnpm dlx shadcn@latest add ",
|
"ui": "pnpm dlx shadcn@latest add ",
|
||||||
"pub": "envision deploy ./dist -k cnb-center -v 0.0.4 -y y -u"
|
"pub": "envision deploy ./dist -k cnb-center -v 0.0.5 -y y -u"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
@@ -21,9 +21,11 @@
|
|||||||
"@ai-sdk/openai": "^3.0.30",
|
"@ai-sdk/openai": "^3.0.30",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
|
"@kevisual/api": "^0.0.60",
|
||||||
"@kevisual/cnb": "^0.0.26",
|
"@kevisual/cnb": "^0.0.26",
|
||||||
"@kevisual/cnb-ai": "^0.0.2",
|
"@kevisual/cnb-ai": "^0.0.2",
|
||||||
"@kevisual/context": "^0.0.8",
|
"@kevisual/context": "^0.0.8",
|
||||||
|
"@kevisual/kv-login": "^0.1.15",
|
||||||
"@kevisual/router": "0.0.80",
|
"@kevisual/router": "0.0.80",
|
||||||
"@tanstack/react-router": "^1.161.1",
|
"@tanstack/react-router": "^1.161.1",
|
||||||
"ai": "^6.0.91",
|
"ai": "^6.0.91",
|
||||||
|
|||||||
2790
pnpm-lock.yaml
generated
Normal file
2790
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
import { QueryRouterServer } from '@kevisual/router/browser'
|
import { QueryRouterServer } from '@kevisual/router/browser'
|
||||||
|
|
||||||
import { useContextKey } from '@kevisual/context'
|
import { useContextKey } from '@kevisual/context'
|
||||||
import { useConfigStore } from '@/app/config/store'
|
import { useConfigStore } from '@/pages/config/store'
|
||||||
import { useGiteaConfigStore } from '@/app/config/gitea/store'
|
import { useGiteaConfigStore } from '@/pages/config/gitea/store'
|
||||||
import { CNB } from '@kevisual/cnb'
|
import { CNB } from '@kevisual/cnb'
|
||||||
import { Gitea } from '@kevisual/gitea';
|
import { Gitea } from '@kevisual/gitea';
|
||||||
export const app = useContextKey('app', new QueryRouterServer())
|
export const app = useContextKey('app', new QueryRouterServer())
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
import { QueryClient } from '@kevisual/query';
|
import { Query, DataOpts } from '@kevisual/query';
|
||||||
|
import { QueryLoginBrowser } from '@kevisual/api/query-login'
|
||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
export const query = useContextKey('query', new Query({
|
||||||
|
url: '/api/router',
|
||||||
|
}));
|
||||||
|
|
||||||
export const query = new QueryClient();
|
export const queryClient = useContextKey('queryClient', new Query({
|
||||||
|
url: '/client/router',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const queryLogin = useContextKey('queryLogin', new QueryLoginBrowser({
|
||||||
|
query: query
|
||||||
|
}));
|
||||||
58
src/pages/auth/index.tsx
Normal file
58
src/pages/auth/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import { useLayoutStore } from "./store"
|
||||||
|
import { useShallow } from "zustand/shallow"
|
||||||
|
import { LogIn, LockKeyhole } from "lucide-react"
|
||||||
|
export { BaseHeader } from './modules/BaseHeader'
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children?: React.ReactNode,
|
||||||
|
mustLogin?: boolean,
|
||||||
|
}
|
||||||
|
export const AuthProvider = ({ children, mustLogin }: Props) => {
|
||||||
|
const store = useLayoutStore(useShallow(state => ({
|
||||||
|
init: state.init,
|
||||||
|
me: state.me,
|
||||||
|
openLinkList: state.openLinkList,
|
||||||
|
})));
|
||||||
|
useEffect(() => {
|
||||||
|
store.init()
|
||||||
|
}, [])
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isOpen = useMemo(() => {
|
||||||
|
return store.openLinkList.some(item => location.pathname.startsWith(item))
|
||||||
|
}, [location.pathname])
|
||||||
|
const loginUrl = '/root/login/?redirect=' + encodeURIComponent(window.location.href);
|
||||||
|
if (mustLogin && !store.me && !isOpen) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="flex flex-col items-center gap-6 p-10 rounded-2xl border border-border bg-card shadow-lg max-w-sm w-full mx-4">
|
||||||
|
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-muted">
|
||||||
|
<LockKeyhole className="w-8 h-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">需要登录</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">请先登录以继续访问此页面</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center gap-2 w-full px-6 py-2.5 rounded-lg bg-foreground text-background text-sm font-medium transition-opacity hover:opacity-80 active:opacity-70"
|
||||||
|
onClick={() => {
|
||||||
|
// window.open(loginUrl, '_blank');
|
||||||
|
navigate({ to: '/login' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
立即登录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
}
|
||||||
80
src/pages/auth/modules/BaseHeader.tsx
Normal file
80
src/pages/auth/modules/BaseHeader.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Home, User, LogIn, LogOut } from 'lucide-react';
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useLayoutStore } from '../store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export const BaseHeader = (props: { main?: React.ComponentType | null }) => {
|
||||||
|
const store = useLayoutStore(useShallow(state => ({
|
||||||
|
me: state.me,
|
||||||
|
clearMe: state.clearMe,
|
||||||
|
links: state.links,
|
||||||
|
})));
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const meInfo = useMemo(() => {
|
||||||
|
if (!store.me) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/login' })}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
<span>去登录</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{store.me.avatar && (
|
||||||
|
<img
|
||||||
|
src={store.me.avatar}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-8 h-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!store.me.avatar && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
|
||||||
|
<User className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-gray-700">{store.me?.username}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => store.clearMe?.()}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-sm text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors cursor-pointer"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [store.me, store.clearMe])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 text-lg w-full h-12 items-center justify-between bg-gray-200">
|
||||||
|
<div className='px-2'>
|
||||||
|
{
|
||||||
|
store.links.map(link => (
|
||||||
|
<Link
|
||||||
|
key={link.key || link.title}
|
||||||
|
to={link.href}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{link.key === 'home' && <Home className="w-4 h-4 mr-1" />}
|
||||||
|
{link.icon && <span className="mr-1">{link.icon}</span>}
|
||||||
|
{link.title}
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className='mr-4'>
|
||||||
|
{meInfo}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LayoutMain = () => {
|
||||||
|
return <BaseHeader />
|
||||||
|
}
|
||||||
81
src/pages/auth/page.tsx
Normal file
81
src/pages/auth/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
import '@kevisual/kv-login';
|
||||||
|
import { checkPluginLogin } from '@kevisual/kv-login'
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLayoutStore } from './store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const LoginComponent = ({ onLoginSuccess }: { onLoginSuccess: () => void }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
// 监听登录成功事件
|
||||||
|
const handleLoginSuccess = () => {
|
||||||
|
console.log('监听到登录成功事件,关闭弹窗');
|
||||||
|
onLoginSuccess();
|
||||||
|
};
|
||||||
|
const loginEmitter = useContextKey('login-emitter')
|
||||||
|
console.log('KvLogin Types:', loginEmitter);
|
||||||
|
|
||||||
|
loginEmitter.on('login-success', handleLoginSuccess);
|
||||||
|
|
||||||
|
// 清理监听器
|
||||||
|
return () => {
|
||||||
|
loginEmitter.off('login-success', handleLoginSuccess);
|
||||||
|
};
|
||||||
|
}, [onLoginSuccess]);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return (<kv-login></kv-login>)
|
||||||
|
}
|
||||||
|
export const App = () => {
|
||||||
|
const store = useLayoutStore(useShallow((state) => ({
|
||||||
|
init: state.init,
|
||||||
|
loginPageConfig: state.loginPageConfig,
|
||||||
|
})));
|
||||||
|
useEffect(() => {
|
||||||
|
checkPluginLogin();
|
||||||
|
}, []);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleLoginSuccess = async () => {
|
||||||
|
await store.init()
|
||||||
|
navigate({ to: '/' })
|
||||||
|
};
|
||||||
|
const { title, subtitle, footer } = store.loginPageConfig;
|
||||||
|
return (
|
||||||
|
<div className='w-full h-full relative overflow-hidden bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900'>
|
||||||
|
{/* 背景装饰 - 圆形光晕 */}
|
||||||
|
<div className='absolute top-1/4 -left-32 w-96 h-96 bg-purple-500/30 rounded-full blur-3xl'></div>
|
||||||
|
<div className='absolute bottom-1/4 -right-32 w-96 h-96 bg-blue-500/30 rounded-full blur-3xl'></div>
|
||||||
|
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-indigo-500/20 rounded-full blur-3xl'></div>
|
||||||
|
|
||||||
|
{/* 背景装饰 - 网格图案 */}
|
||||||
|
<div className='absolute inset-0 opacity-[0.03] bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]'></div>
|
||||||
|
|
||||||
|
{/* 顶部装饰文字 */}
|
||||||
|
<div className='absolute top-10 left-0 right-0 text-center'>
|
||||||
|
<h1 className='text-4xl font-bold text-white/90 tracking-wider'>{title}</h1>
|
||||||
|
<p className='mt-2 text-white/50 text-sm tracking-widest'>{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 登录卡片容器 */}
|
||||||
|
<div className='w-full h-full flex items-center justify-center p-8'>
|
||||||
|
<div className='relative'>
|
||||||
|
{/* 卡片外圈光效 */}
|
||||||
|
<div className='absolute -inset-1 bg-gradient-to-r from-purple-500 via-blue-500 to-indigo-500 rounded-2xl blur opacity-30'></div>
|
||||||
|
|
||||||
|
{/* 登录组件容器 */}
|
||||||
|
<div className='relative bg-slate-900/80 backdrop-blur-xl rounded-2xl border border-white/10 shadow-2xl overflow-hidden'>
|
||||||
|
<LoginComponent onLoginSuccess={handleLoginSuccess} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部装饰 */}
|
||||||
|
<div className='absolute bottom-6 left-0 right-0 text-center'>
|
||||||
|
<p className='text-white/30 text-xs'>{footer}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
103
src/pages/auth/store.ts
Normal file
103
src/pages/auth/store.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
|
||||||
|
import { queryLogin } from '@/modules/query';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
type UserInfo = {
|
||||||
|
id?: string;
|
||||||
|
username?: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
needChangePassword?: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
type?: 'user' | 'org';
|
||||||
|
orgs?: string[];
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
export type LayoutStore = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
openUser: boolean;
|
||||||
|
setOpenUser: (openUser: boolean) => void;
|
||||||
|
me?: UserInfo;
|
||||||
|
setMe: (me: UserInfo) => void;
|
||||||
|
clearMe: () => void;
|
||||||
|
getMe: () => Promise<void>;
|
||||||
|
switchOrg: (username?: string) => Promise<void>;
|
||||||
|
isAdmin: boolean;
|
||||||
|
setIsAdmin: (isAdmin: boolean) => void
|
||||||
|
init: () => Promise<void>;
|
||||||
|
openLinkList: string[];
|
||||||
|
setOpenLinkList: (openLinkList: string[]) => void;
|
||||||
|
loginPageConfig: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
footer: string;
|
||||||
|
};
|
||||||
|
setLoginPageConfig: (config: Partial<LayoutStore['loginPageConfig']>) => void;
|
||||||
|
links: HeaderLink[];
|
||||||
|
};
|
||||||
|
type HeaderLink = {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLayoutStore = create<LayoutStore>((set, get) => ({
|
||||||
|
open: false,
|
||||||
|
setOpen: (open) => set({ open }),
|
||||||
|
openUser: false,
|
||||||
|
setOpenUser: (openUser) => set({ openUser }),
|
||||||
|
me: undefined,
|
||||||
|
setMe: (me) => set({ me }),
|
||||||
|
clearMe: () => {
|
||||||
|
set({ me: undefined, isAdmin: false });
|
||||||
|
window.location.href = '/root/login/?redirect=' + encodeURIComponent(window.location.href);
|
||||||
|
},
|
||||||
|
getMe: async () => {
|
||||||
|
const res = await queryLogin.getMe();
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ me: res.data });
|
||||||
|
set({ isAdmin: res.data.orgs?.includes?.('admin') || false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
switchOrg: async (username?: string) => {
|
||||||
|
const res = await queryLogin.switchUser(username || '');
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('切换成功');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isAdmin: false,
|
||||||
|
setIsAdmin: (isAdmin) => set({ isAdmin }),
|
||||||
|
init: async () => {
|
||||||
|
const token = await queryLogin.getToken();
|
||||||
|
if (token) {
|
||||||
|
set({ me: {} })
|
||||||
|
const me = await queryLogin.getMe();
|
||||||
|
// const user = await queryLogin.checkLocalUser() as UserInfo;
|
||||||
|
const user = me.code === 200 ? me.data : undefined;
|
||||||
|
if (user) {
|
||||||
|
set({ me: user });
|
||||||
|
set({ isAdmin: user.orgs?.includes?.('admin') || false });
|
||||||
|
} else {
|
||||||
|
set({ me: undefined, isAdmin: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openLinkList: ['/login'],
|
||||||
|
setOpenLinkList: (openLinkList) => set({ openLinkList }),
|
||||||
|
loginPageConfig: {
|
||||||
|
title: '可视化管理平台',
|
||||||
|
subtitle: '让工具和智能化触手可及',
|
||||||
|
footer: '欢迎使用可视化管理平台 · 连接您的工具',
|
||||||
|
},
|
||||||
|
setLoginPageConfig: (config) => set((state) => ({
|
||||||
|
loginPageConfig: { ...state.loginPageConfig, ...config },
|
||||||
|
})),
|
||||||
|
links: [{ title: '首页', href: '/', key: 'home' }],
|
||||||
|
}));
|
||||||
16
src/pages/config/hooks/check.ts
Normal file
16
src/pages/config/hooks/check.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useConfigStore } from "../store";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const useCheckConfig = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const config = useConfigStore.getState().config;
|
||||||
|
if (!config.CNB_API_KEY) {
|
||||||
|
navigate({
|
||||||
|
to: '/config'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useShallow } from 'zustand/shallow'
|
||||||
|
|
||||||
type LinkItemKey = keyof WorkspaceOpen;
|
type LinkItemKey = keyof WorkspaceOpen;
|
||||||
interface LinkItem {
|
interface LinkItem {
|
||||||
@@ -94,8 +95,76 @@ const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dev tab 内容
|
||||||
|
const DevTabContent = ({ linkItems, workspaceLink, stopWorkspace }: {
|
||||||
|
linkItems: LinkItem[]
|
||||||
|
workspaceLink: Partial<WorkspaceOpen>
|
||||||
|
stopWorkspace: () => void
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => stopWorkspace()}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4" />
|
||||||
|
停止工作区
|
||||||
|
</button>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||||
|
{linkItems.map((item) => (
|
||||||
|
<LinkItem
|
||||||
|
key={item.key}
|
||||||
|
label={item.label}
|
||||||
|
icon={item.icon}
|
||||||
|
url={item.getUrl(workspaceLink)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work tab 内容(暂留,需要根据 business_id 做事情)
|
||||||
|
const WorkTabContent = () => {
|
||||||
|
const store = useRepoStore(useShallow((state) => ({ selectWorkspace: state.selectWorkspace })))
|
||||||
|
const businessId = store.selectWorkspace?.business_id;
|
||||||
|
|
||||||
|
const appList = [
|
||||||
|
{
|
||||||
|
title: 'Kevisual Assistant Client', key: 'Assistant Client', port: 51515, end: '/root/cli-center/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'OpenCode', key: 'OpenCode', port: 100, end: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'OpenClaw', key: 'OpenClaw', port: 80, end: '/openclaw'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'OpenWebUI', key: 'OpenWebUI', port: 200, end: ''
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const links = appList.map(app => {
|
||||||
|
const url = `https://${businessId}-${app.port}.cnb.run${app.end}`
|
||||||
|
return {
|
||||||
|
label: app.title,
|
||||||
|
icon: <Terminal className="w-5 h-5" />,
|
||||||
|
url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-2 text-neutral-400">
|
||||||
|
<div className='mb-2'>小代理</div>
|
||||||
|
<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.icon} url={link.url} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkspaceDetailDialog() {
|
export function WorkspaceDetailDialog() {
|
||||||
const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink, stopWorkspace } = useRepoStore()
|
const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink, stopWorkspace, workspaceTab, setWorkspaceTab } = useRepoStore()
|
||||||
|
|
||||||
const linkItems: LinkItem[] = [
|
const linkItems: LinkItem[] = [
|
||||||
{
|
{
|
||||||
@@ -170,22 +239,40 @@ export function WorkspaceDetailDialog() {
|
|||||||
<DialogTitle className="text-neutral-900">打开工作区</DialogTitle>
|
<DialogTitle className="text-neutral-900">打开工作区</DialogTitle>
|
||||||
<DialogDescription className="text-neutral-500">选择一个编辑器或方式来打开工作区</DialogDescription>
|
<DialogDescription className="text-neutral-500">选择一个编辑器或方式来打开工作区</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<button
|
{/* Tab 导航 */}
|
||||||
onClick={() => stopWorkspace()}
|
<div className="flex border-b border-neutral-200">
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium transition-colors"
|
<button
|
||||||
>
|
onClick={() => setWorkspaceTab('dev')}
|
||||||
<Square className="w-4 h-4" />
|
className={`cursor-pointer flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${workspaceTab === 'dev'
|
||||||
停止工作区
|
? 'text-neutral-900'
|
||||||
</button>
|
: 'text-neutral-500 hover:text-neutral-700'
|
||||||
<div className="grid grid-cols-2 gap-3">
|
}`}
|
||||||
{linkItems.map((item) => (
|
>
|
||||||
<LinkItem
|
Dev
|
||||||
key={item.key}
|
{workspaceTab === 'dev' && (
|
||||||
label={item.label}
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
|
||||||
icon={item.icon}
|
)}
|
||||||
url={item.getUrl(workspaceLink)}
|
</button>
|
||||||
/>
|
<button
|
||||||
))}
|
onClick={() => setWorkspaceTab('work')}
|
||||||
|
className={`cursor-pointer flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${workspaceTab === 'work'
|
||||||
|
? 'text-neutral-900'
|
||||||
|
: 'text-neutral-500 hover:text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Work
|
||||||
|
{workspaceTab === 'work' && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-neutral-900" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Tab 内容 */}
|
||||||
|
<div className="py-2">
|
||||||
|
{workspaceTab === 'dev' ? (
|
||||||
|
<DevTabContent linkItems={linkItems} workspaceLink={workspaceLink} stopWorkspace={stopWorkspace} />
|
||||||
|
) : (
|
||||||
|
<WorkTabContent />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -49,6 +49,8 @@ interface Data {
|
|||||||
pinned_time: string;
|
pinned_time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceTabType = 'dev' | 'work'
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
formData: Record<string, any>;
|
formData: Record<string, any>;
|
||||||
setFormData: (data: Record<string, any>) => void;
|
setFormData: (data: Record<string, any>) => void;
|
||||||
@@ -56,6 +58,8 @@ type State = {
|
|||||||
setShowEdit: (showEdit: boolean) => void;
|
setShowEdit: (showEdit: boolean) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
|
workspaceTab: WorkspaceTabType;
|
||||||
|
setWorkspaceTab: (tab: WorkspaceTabType) => void;
|
||||||
list: Data[];
|
list: Data[];
|
||||||
editRepo: Data | null;
|
editRepo: Data | null;
|
||||||
setEditRepo: (repo: Data | null) => void;
|
setEditRepo: (repo: Data | null) => void;
|
||||||
@@ -102,6 +106,8 @@ export const useRepoStore = create<State>((set, get) => {
|
|||||||
setShowCreateDialog: (show) => set({ showCreateDialog: show }),
|
setShowCreateDialog: (show) => set({ showCreateDialog: show }),
|
||||||
showWorkspaceDialog: false,
|
showWorkspaceDialog: false,
|
||||||
setShowWorkspaceDialog: (show) => set({ showWorkspaceDialog: show }),
|
setShowWorkspaceDialog: (show) => set({ showWorkspaceDialog: show }),
|
||||||
|
workspaceTab: 'dev',
|
||||||
|
setWorkspaceTab: (tab) => set({ workspaceTab: tab }),
|
||||||
syncDialogOpen: false,
|
syncDialogOpen: false,
|
||||||
setSyncDialogOpen: (open) => set({ syncDialogOpen: open }),
|
setSyncDialogOpen: (open) => set({ syncDialogOpen: open }),
|
||||||
selectedSyncRepo: null,
|
selectedSyncRepo: null,
|
||||||
@@ -9,10 +9,16 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ConfigIndexRouteImport } from './routes/config/index'
|
import { Route as ConfigIndexRouteImport } from './routes/config/index'
|
||||||
import { Route as ConfigGiteaRouteImport } from './routes/config/gitea'
|
import { Route as ConfigGiteaRouteImport } from './routes/config/gitea'
|
||||||
|
|
||||||
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -31,36 +37,47 @@ const ConfigGiteaRoute = ConfigGiteaRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/config/gitea': typeof ConfigGiteaRoute
|
'/config/gitea': typeof ConfigGiteaRoute
|
||||||
'/config/': typeof ConfigIndexRoute
|
'/config/': typeof ConfigIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/config/gitea': typeof ConfigGiteaRoute
|
'/config/gitea': typeof ConfigGiteaRoute
|
||||||
'/config': typeof ConfigIndexRoute
|
'/config': typeof ConfigIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/config/gitea': typeof ConfigGiteaRoute
|
'/config/gitea': typeof ConfigGiteaRoute
|
||||||
'/config/': typeof ConfigIndexRoute
|
'/config/': typeof ConfigIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/config/gitea' | '/config/'
|
fullPaths: '/' | '/login' | '/config/gitea' | '/config/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/config/gitea' | '/config'
|
to: '/' | '/login' | '/config/gitea' | '/config'
|
||||||
id: '__root__' | '/' | '/config/gitea' | '/config/'
|
id: '__root__' | '/' | '/login' | '/config/gitea' | '/config/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
ConfigGiteaRoute: typeof ConfigGiteaRoute
|
ConfigGiteaRoute: typeof ConfigGiteaRoute
|
||||||
ConfigIndexRoute: typeof ConfigIndexRoute
|
ConfigIndexRoute: typeof ConfigIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -87,6 +104,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
ConfigGiteaRoute: ConfigGiteaRoute,
|
ConfigGiteaRoute: ConfigGiteaRoute,
|
||||||
ConfigIndexRoute: ConfigIndexRoute,
|
ConfigIndexRoute: ConfigIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,27 @@
|
|||||||
import { Link, Outlet, createRootRoute, useNavigate } from '@tanstack/react-router'
|
import { LayoutMain } from '@/pages/auth/modules/BaseHeader';
|
||||||
|
import { Outlet, createRootRoute } 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 { AuthProvider } from '@/pages/auth'
|
||||||
import { useEffect } from 'react'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { Settings } from 'lucide-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 className='h-full overflow-hidden'>
|
<div className='h-full overflow-hidden'>
|
||||||
|
<LayoutMain />
|
||||||
<div className="p-2 flex gap-2 text-lg items-center">
|
<AuthProvider mustLogin={false}>
|
||||||
<Link
|
<TooltipProvider>
|
||||||
to="/"
|
<main className='h-[calc(100%-3rem)] overflow-auto scrollbar'>
|
||||||
activeProps={{
|
<Outlet />
|
||||||
className: 'text-gray-800',
|
</main>
|
||||||
}}
|
</TooltipProvider>
|
||||||
inactiveProps={{
|
</AuthProvider>
|
||||||
className: 'text-gray-500',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
仓库列表
|
|
||||||
</Link>
|
|
||||||
<Link to='/config' activeProps={{
|
|
||||||
className: 'text-gray-800',
|
|
||||||
}}
|
|
||||||
inactiveProps={{
|
|
||||||
className: 'text-gray-500',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings className="h-5 w-5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<main className='h-[calc(100%-4rem)] overflow-auto scrollbar'>
|
|
||||||
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
<TanStackRouterDevtools position="bottom-right" />
|
<TanStackRouterDevtools position="bottom-right" />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import App from '@/app/config/gitea/page'
|
import App from '@/pages/config/gitea/page'
|
||||||
export const Route = createFileRoute('/config/gitea')({
|
export const Route = createFileRoute('/config/gitea')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import App from '@/app/config/page'
|
import App from '@/pages/config/page'
|
||||||
export const Route = createFileRoute('/config/')({
|
export const Route = createFileRoute('/config/')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import App from '@/app/page'
|
import App from '@/pages/page'
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
|
|||||||
9
src/routes/login.tsx
Normal file
9
src/routes/login.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import App from '@/pages/auth/page'
|
||||||
|
export const Route = createFileRoute('/login')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user