初始化模版工程
This commit is contained in:
150
components/nova-sdk/tools/README.md
Normal file
150
components/nova-sdk/tools/README.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Nova SDK Tools
|
||||
|
||||
这个目录包含了从 Remix 迁移的工具相关组件,用于管理自定义工具(SKILL、MCP、API)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tools/
|
||||
├── components/ # 主要组件
|
||||
│ ├── api-form.tsx # API 工具表单
|
||||
│ ├── custom-list-view.tsx # 自定义工具列表视图
|
||||
│ ├── message-mcp.tsx # MCP 消息组件
|
||||
│ ├── mcp-json-editor.tsx # MCP JSON 编辑器
|
||||
│ ├── skill-form.tsx # SKILL 工具表单
|
||||
│ ├── tool-item.tsx # 工具项组件
|
||||
│ └── tool-type-badge.tsx # 工具类型徽章
|
||||
├── settings/ # 设置相关组件
|
||||
│ ├── ai-parse-config.tsx # AI 解析配置
|
||||
│ ├── example-popover.tsx # 示例弹出框
|
||||
│ ├── mcp-editor-modal.tsx # MCP 编辑模态框
|
||||
│ └── mcp-store-popover.tsx # MCP 商店弹出框
|
||||
├── hooks/ # React Hooks
|
||||
│ └── useExampleList.ts # 获取示例列表
|
||||
├── form-components/ # 表单组件
|
||||
│ ├── path-form-item.tsx # 路径表单项
|
||||
│ ├── form-header-item.tsx # 表单头项
|
||||
│ ├── nest-form-request-form-item/ # 嵌套表单请求项
|
||||
│ ├── env-manage/ # 环境管理
|
||||
│ └── parse-curl/ # 解析 curl
|
||||
└── index.ts # 主导出文件
|
||||
```
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 1. SKILL 工具管理
|
||||
- `SkillForm`: 创建和编辑 SKILL 工具的表单组件
|
||||
|
||||
### 2. MCP 工具管理
|
||||
- `MCPEditorModal`: MCP 服务编辑模态框
|
||||
- `MCPJsonEditor`: JSON 格式的 MCP 配置编辑器
|
||||
- `MessageMCP`: MCP 工具消息展示组件
|
||||
- `ExamplePopover`: MCP 示例选择器
|
||||
- `AiParseConfig`: AI 辅助解析 MCP 配置
|
||||
- `McpStorePopover`: MCP 市场链接
|
||||
|
||||
### 3. API 工具管理
|
||||
- `ApiForm`: 创建和编辑 API 工具的表单组件
|
||||
- 各种表单组件支持 API 参数配置
|
||||
|
||||
### 4. 工具列表视图
|
||||
- `MCPListView`: 自定义工具列表视图,包含 SKILL、MCP、API 三种类型
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 导入组件
|
||||
|
||||
```typescript
|
||||
// 导入所有工具组件
|
||||
import { SkillForm, MCPEditorModal, MCPListView } from '@/nova-sdk/tools'
|
||||
|
||||
// 或者按需导入
|
||||
import { SkillForm } from '@/nova-sdk/tools/components/skill-form'
|
||||
import { MCPEditorModal } from '@/nova-sdk/tools/settings/mcp-editor-modal'
|
||||
import { MCPListView } from '@/nova-sdk/tools/components/custom-list-view'
|
||||
```
|
||||
|
||||
### 使用 SKILL 表单
|
||||
|
||||
```typescript
|
||||
import { SkillForm } from '@/nova-sdk/tools'
|
||||
import type { SkillFormState } from '@/nova-sdk/tools'
|
||||
|
||||
function MyComponent() {
|
||||
const handleSave = async (values: SkillFormState) => {
|
||||
// 处理保存逻辑
|
||||
console.log(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<SkillForm
|
||||
teamId="your-team-id"
|
||||
onBack={() => console.log('back')}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 使用 MCP 编辑器
|
||||
|
||||
```typescript
|
||||
import { MCPEditorModal } from '@/nova-sdk/tools'
|
||||
|
||||
function MyComponent() {
|
||||
const handleFinish = async (values: any) => {
|
||||
// 保存 MCP 配置
|
||||
console.log(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<MCPEditorModal
|
||||
value={mcpConfig}
|
||||
isEdit={false}
|
||||
exampleList={exampleList}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 使用工具列表视图
|
||||
|
||||
```typescript
|
||||
import { MCPListView } from '@/nova-sdk/tools'
|
||||
|
||||
function MyComponent() {
|
||||
const handleViewChange = (view: ViewType) => {
|
||||
console.log('View changed:', view)
|
||||
}
|
||||
|
||||
return (
|
||||
<MCPListView
|
||||
teamId="your-team-id"
|
||||
onViewChange={handleViewChange}
|
||||
onClose={() => console.log('close')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 依赖说明
|
||||
|
||||
这些组件依赖于以下外部库:
|
||||
- `antd`: UI 组件库
|
||||
- `ahooks`: React Hooks 工具库
|
||||
- `@ebay/nice-modal-react`: 模态框管理
|
||||
|
||||
以及内部依赖:
|
||||
- `@/store/tool`: 工具状态管理
|
||||
- `@/store/mcp`: MCP 状态管理
|
||||
- `@/components/icon`: 图标组件
|
||||
- `@/components/modal`: 模态框组件
|
||||
- `@apis/mindnote/*`: API 接口
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 迁移的组件保留了原有的功能和接口
|
||||
2. 所有组件都使用 TypeScript 类型定义
|
||||
3. 表单验证和错误处理逻辑保持不变
|
||||
4. 确保在使用前正确配置相关 Store 和 API
|
||||
61
components/nova-sdk/tools/REPLACE_SUMMARY.md
Normal file
61
components/nova-sdk/tools/REPLACE_SUMMARY.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Nova SDK Tools - 组件替换总结
|
||||
|
||||
## ✅ 已完成工作
|
||||
|
||||
### 1. 清理和简化
|
||||
- ✅ 删除了所有 API 相关组件(api-form, form-components)
|
||||
- ✅ 删除了列表查看功能(custom-list-view, tool-item, tool-type-badge)
|
||||
- ✅ 删除了示例功能(useExampleList, example-popover)
|
||||
- ✅ 删除了所有 localize 相关代码
|
||||
|
||||
### 2. 只保留 SKILL 和 MCP 组件
|
||||
**剩余文件(6个):**
|
||||
```
|
||||
tools/
|
||||
├── components/
|
||||
│ ├── skill-form.tsx ✅ 已替换为 shadcn + lucide-react
|
||||
│ ├── mcp-json-editor.tsx ✅ 已替换为 shadcn + lucide-react
|
||||
│ └── message-mcp.tsx 🔄 待替换
|
||||
├── settings/
|
||||
│ ├── mcp-editor-modal.tsx 🔄 待替换
|
||||
│ ├── ai-parse-config.tsx 🔄 待替换
|
||||
│ └── mcp-store-popover.tsx 🔄 待替换
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
### 3. UI 框架替换进度
|
||||
|
||||
**已完成(2/6):**
|
||||
- ✅ skill-form.tsx - Button, Input, Textarea, Label, toast (shadcn) + Inbox (lucide-react)
|
||||
- ✅ mcp-json-editor.tsx - Button, Textarea, toast (shadcn)
|
||||
|
||||
**待完成(4/6):**
|
||||
- 🔄 message-mcp.tsx - 需替换 Spin, Button
|
||||
- 🔄 mcp-editor-modal.tsx - 需替换 Button, Form, Input, message, Select, Tooltip
|
||||
- 🔄 ai-parse-config.tsx - 需替换 Button, Form, Input, message, Popover, Tooltip
|
||||
- 🔄 mcp-store-popover.tsx - 需替换 Popover, Tooltip
|
||||
|
||||
## 📋 替换方案
|
||||
|
||||
### antd → shadcn/ui 映射表
|
||||
| antd | shadcn/ui |
|
||||
|------|-----------|
|
||||
| Button | Button |
|
||||
| Input | Input |
|
||||
| Input.TextArea | Textarea |
|
||||
| Form | Label + Input/Textarea + 自定义验证 |
|
||||
| Select | Select |
|
||||
| message | toast (from @/hooks/use-toast) |
|
||||
| Popover | Popover |
|
||||
| Tooltip | Tooltip |
|
||||
| Spin | Loader2 (from lucide-react) |
|
||||
| Upload | 自定义文件上传 |
|
||||
|
||||
### @ant-design/icons → lucide-react 映射表
|
||||
| antd icons | lucide-react |
|
||||
|------------|--------------|
|
||||
| InboxOutlined | Inbox |
|
||||
| 其他图标 | 相应的 lucide-react 图标 |
|
||||
|
||||
## 🎯 下一步
|
||||
继续替换剩余 4 个文件中的 antd 组件为 shadcn/ui
|
||||
7
components/nova-sdk/tools/components/index.ts
Normal file
7
components/nova-sdk/tools/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Skill Form
|
||||
export { SkillForm } from './skill-form'
|
||||
export type { SkillFormState } from './skill-form'
|
||||
|
||||
// MCP Components
|
||||
export { MCPJsonEditor } from './mcp-json-editor'
|
||||
export type { MCPJsonEditorRef } from './mcp-json-editor'
|
||||
148
components/nova-sdk/tools/components/mcp-json-editor.tsx
Normal file
148
components/nova-sdk/tools/components/mcp-json-editor.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useMemoizedFn } from 'ahooks'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface MCPJsonEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSave?: (parsedConfig: Record<string, unknown>) => void | Promise<void>
|
||||
showButtons?: boolean
|
||||
}
|
||||
|
||||
export interface MCPJsonEditorRef {
|
||||
triggerSave: () => Promise<void>
|
||||
}
|
||||
|
||||
export const MCPJsonEditor = forwardRef<MCPJsonEditorRef, MCPJsonEditorProps>(
|
||||
({ value, onChange, onSave, showButtons = true }, ref) => {
|
||||
const [jsonValue, setJsonValue] = useState(value)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const parseJsonToConfig = useMemoizedFn(async () => {
|
||||
if (isSaving) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = JSON.parse(jsonValue)
|
||||
const { mcpServers = {} as Record<string, unknown> } = res
|
||||
const mcpServer = Object.entries(mcpServers)[0]
|
||||
|
||||
if (!mcpServer) {
|
||||
toast.error('未检测到合法的MCP配置')
|
||||
return
|
||||
}
|
||||
|
||||
const [key, value] = mcpServer as [string, Record<string, unknown>]
|
||||
const code = key
|
||||
const command = value.command ?? ''
|
||||
const args = value.args ?? []
|
||||
const env = value.env ?? {}
|
||||
const url = value.url ?? ''
|
||||
|
||||
if ((!url && !command) || !code) {
|
||||
toast.error('非法配置')
|
||||
return
|
||||
}
|
||||
|
||||
const type = command ? 'command' : 'sse'
|
||||
const argsString = (args as string[]).join(' ')
|
||||
const envString = Object.entries(env)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
|
||||
if (
|
||||
type === 'command' &&
|
||||
!['npx', 'uv', 'uvx'].includes(value.command as string ?? '')
|
||||
) {
|
||||
toast.error(`非法配置,不支持的命令: ${value.command}`)
|
||||
return
|
||||
}
|
||||
|
||||
const parsedConfig = {
|
||||
type,
|
||||
command,
|
||||
code,
|
||||
name: code,
|
||||
args: argsString,
|
||||
env: envString,
|
||||
url,
|
||||
headers: value.headers ?? '',
|
||||
}
|
||||
|
||||
onChange(jsonValue)
|
||||
await onSave?.(parsedConfig)
|
||||
} catch {
|
||||
toast.error('解析失败,请检查JSON格式')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
})
|
||||
|
||||
const handleJsonChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
setJsonValue(newValue)
|
||||
onChange(newValue)
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
triggerSave: parseJsonToConfig,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className='w-full h-full flex flex-col' style={{ pointerEvents: 'auto' }}>
|
||||
<div className='flex-1 flex flex-col' style={{ pointerEvents: 'auto' }}>
|
||||
<Textarea
|
||||
className='flex-1 rounded-xl border border-input bg-card focus:border-primary/40 focus:ring-2 focus:ring-ring'
|
||||
style={{
|
||||
resize: 'none',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", consolas, "source-code-pro", monospace',
|
||||
minHeight: '300px',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 1,
|
||||
position: 'relative',
|
||||
cursor: 'text',
|
||||
caretColor: 'var(--primary)',
|
||||
outline: 'none',
|
||||
userSelect: 'text',
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={`仅支持解析一个MCP,如果有多个仅解析第一个,示例:
|
||||
{
|
||||
"mcpServers": {
|
||||
"RedNote MCP": {
|
||||
"command": "npx",
|
||||
"args": ["rednote-mcp", "--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
或者 SSE 方式:
|
||||
{
|
||||
"mcpServers": {
|
||||
"Weather MCP": {
|
||||
"url": "http://localhost:8000/mcp"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
value={jsonValue}
|
||||
onChange={handleJsonChange}
|
||||
/>
|
||||
</div>
|
||||
{showButtons && (
|
||||
<div className='shrink-0 pt-3 flex items-center justify-end gap-3'>
|
||||
<Button onClick={parseJsonToConfig} disabled={isSaving}>
|
||||
{isSaving && <Loader2 className='h-4 w-4 animate-spin' />}
|
||||
{isSaving ? '解析中...' : '解析并保存'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
MCPJsonEditor.displayName = 'MCPJsonEditor'
|
||||
147
components/nova-sdk/tools/components/skill-form.tsx
Normal file
147
components/nova-sdk/tools/components/skill-form.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useMemoizedFn } from 'ahooks'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { Inbox } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface SkillFormState {
|
||||
name: string
|
||||
description?: string
|
||||
file?: { file: File; fileList: File[] }
|
||||
}
|
||||
|
||||
interface SkillFormProps {
|
||||
values?: { name: string; description?: string }
|
||||
className?: string
|
||||
teamId: string
|
||||
onBack: () => void
|
||||
onSave: (values: SkillFormState) => Promise<void>
|
||||
}
|
||||
|
||||
const name_required_text = '请输入SKILL工具名称'
|
||||
const file_tooltip_text = '仅支持上传 .zip 文件'
|
||||
|
||||
export const SkillForm = memo<SkillFormProps>(
|
||||
({ className, values, onBack, onSave }) => {
|
||||
const [name, setName] = useState(values?.name || '')
|
||||
const [description, setDescription] = useState(values?.description || '')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const isEdit = !!values
|
||||
|
||||
useEffect(() => {
|
||||
if (values) {
|
||||
setName(values.name)
|
||||
setDescription(values.description || '')
|
||||
}
|
||||
}, [values])
|
||||
|
||||
const handleSave = useMemoizedFn(async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('错误')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEdit && !file) {
|
||||
toast.error('错误')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await onSave({
|
||||
name,
|
||||
description,
|
||||
file: file ? { file: file as File, fileList: [file as File] } : undefined
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
const isZip = selectedFile.type === 'application/zip' || selectedFile.name.endsWith('.zip')
|
||||
if (!isZip) {
|
||||
toast.error('错误')
|
||||
return
|
||||
}
|
||||
setFile(selectedFile)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-transparent text-foreground'>
|
||||
<div className={cn('flex-1 p-24px overflow-y-auto space-y-6', className)}>
|
||||
<div className='space-y-2 px-[4px]'>
|
||||
<Label htmlFor='name' className='mb-[] text-sm font-medium text-foreground'>名称</Label>
|
||||
<Input
|
||||
id='name'
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={name_required_text}
|
||||
maxLength={100}
|
||||
className='mt-[8px] border-border bg-card text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2 px-[4px]'>
|
||||
<Label htmlFor='description' className='text-sm font-medium text-foreground'>描述</Label>
|
||||
<Textarea
|
||||
id='description'
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder='请输入SKILL工具的描述'
|
||||
rows={3}
|
||||
className='mt-[8px] min-h-[120px] resize-y border-border bg-card text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2 px-[4px]'>
|
||||
<Label htmlFor='file' className='text-sm font-medium text-foreground'>文件</Label>
|
||||
<div className='relative mt-[8px] cursor-pointer rounded-lg border-2 border-dashed border-border bg-card p-8 text-center transition-colors hover:border-primary'>
|
||||
<Inbox className='mx-auto mb-4 h-12 w-12 cursor-pointer text-muted-foreground' />
|
||||
<input
|
||||
id='file'
|
||||
type='file'
|
||||
accept='.zip,application/zip'
|
||||
onChange={handleFileChange}
|
||||
className='opacity-0 absolute top-0 left-0 w-full h-full'
|
||||
/>
|
||||
<label htmlFor='file' className='cursor-pointer'>
|
||||
<p className='text-sm text-foreground'>
|
||||
{file ? file.name : '点击或拖拽文件到这里上传'}
|
||||
</p>
|
||||
<p className='mt-2 text-xs text-muted-foreground'>{file_tooltip_text}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[16px] flex justify-end gap-8px border-t border-border/70 bg-transparent px-24px pb-24px pt-16px'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={onBack}
|
||||
className='mr-[8px] hover:bg-transparent hover:border-border hover:text-foreground'
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
SkillForm.displayName = 'SkillForm'
|
||||
2
components/nova-sdk/tools/hooks/index.ts
Normal file
2
components/nova-sdk/tools/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Hooks
|
||||
// useExampleList removed - only create functionality needed
|
||||
8
components/nova-sdk/tools/index.ts
Normal file
8
components/nova-sdk/tools/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Skill Components
|
||||
export * from './components/skill-form'
|
||||
|
||||
// MCP Components
|
||||
export * from './components/mcp-json-editor'
|
||||
|
||||
// MCP Settings
|
||||
export * from './settings/mcp-store-popover'
|
||||
2
components/nova-sdk/tools/settings/index.ts
Normal file
2
components/nova-sdk/tools/settings/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Settings Components
|
||||
export { McpStorePopover } from './mcp-store-popover'
|
||||
68
components/nova-sdk/tools/settings/mcp-store-popover.tsx
Normal file
68
components/nova-sdk/tools/settings/mcp-store-popover.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { memo, useState } from 'react'
|
||||
import { ExternalLink, Store, Globe } from 'lucide-react'
|
||||
|
||||
const storeList = [
|
||||
{
|
||||
name: 'modelscope.cn',
|
||||
link: 'https://www.modelscope.cn/mcp',
|
||||
},
|
||||
{
|
||||
name: 'mcpmarket.cn',
|
||||
link: 'https://mcpmarket.cn',
|
||||
},
|
||||
{
|
||||
name: 'mcp.so',
|
||||
link: 'https://mcp.so',
|
||||
},
|
||||
]
|
||||
|
||||
export const McpStorePopover = memo(() => {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center justify-center rounded-full w-7 h-7 bg-gray-100 text-gray-900 transition-colors hover:bg-gray-200">
|
||||
<Store className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>MCP 市场</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<PopoverContent align="start" className="w-60 border border-border bg-popover p-1">
|
||||
<div className="px-3 py-2 text-sm font-medium text-foreground">
|
||||
MCP 市场
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
{storeList.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.name}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-lg px-3 py-3 transition-colors hover:bg-accent"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open(item.link, '_blank')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-sm text-foreground">{item.name}</div>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user