Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions web_v2/src/app/[locale]/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import { Sidebar } from '@/components/layout';
import { SidebarProvider } from '@/components/providers/sidebar-provider';

export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen bg-sidebar">
{/* 左侧边栏 */}
<Sidebar />
<SidebarProvider>
<div className="flex min-h-screen bg-sidebar">
{/* 左侧边栏 */}
<Sidebar />

{/* 主内容区域 */}
<main className="flex-1 ml-64 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="h-full">
{children}
</div>
</main>
</div>
{/* 主内容区域 */}
<main className="flex-1 ml-64 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="h-full">
{children}
</div>
</main>
</div>
</SidebarProvider>
);
}
105 changes: 105 additions & 0 deletions web_v2/src/app/[locale]/(dashboard)/models/hooks/useEndpointData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useState, useEffect, useCallback, useRef } from "react"
import { EndpointDetails } from "@/lib/types/openapi"
import { getEndpointDetails } from "@/lib/api/meta"
import { processModelsArray } from "@/components/ui/models/utils"

/**
* 自定义 Hook: 管理端点数据的获取和状态
*/
export const useEndpointData = (endpoint: string, selectedTags: string[]) => {
Copy link
Collaborator

@fengyizhu fengyizhu Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

看起来 endpoint、models这些基础数据非常通用,建议规划到一个 context 对象结构, 把定义和 获取拆开。可以单开issues处理

// 拆分为三个独立的 state
const [endpointInfo, setEndpointInfo] = useState<EndpointDetails['endpoint'] | null>(null)
const [features, setFeatures] = useState<EndpointDetails['features']>([])
const [models, setModels] = useState<EndpointDetails['models']>([])

// 区分两种 loading 状态
const [initialLoading, setInitialLoading] = useState(false) // 首次加载或切换 endpoint 时的 loading
const [modelsLoading, setModelsLoading] = useState(false) // 仅更新模型列表时的 loading
const [error, setError] = useState<Error | null>(null)

// 使用 ref 保存上次的 endpoint,用于判断是否需要更新 features
const prevEndpointRef = useRef<string>("")

/**
* 获取并处理端点详情数据
*/
const fetchAndProcessData = useCallback(async (endpoint: string, modelName: string = "", tags: string[], shouldUpdateFeatures: boolean) => {
if (!endpoint) return

try {
// 根据是否更新 features 决定使用哪种 loading 状态
if (shouldUpdateFeatures) {
setInitialLoading(true)
} else {
setModelsLoading(true)
}
setError(null)

// 获取原始数据
const data = await getEndpointDetails(endpoint, modelName, tags)

// 处理模型数据
const processedModels = data?.models ? processModelsArray(data.models) : []

// 根据 shouldUpdateFeatures 决定是否更新 features
if (shouldUpdateFeatures) {
// endpoint 变化时,完全更新所有数据(包括 endpoint、features 和 models)
setEndpointInfo(data?.endpoint || null)
setFeatures(data?.features || [])
setModels(processedModels)
} else {
// 仅 tags 变化时,只更新 models,保持 endpoint 和 features 不变
setModels(processedModels)
}
} catch (err) {
console.error('Error fetching endpoint details:', err)
setError(err instanceof Error ? err : new Error('Unknown error'))
setEndpointInfo(null)
setFeatures([])
setModels([])
} finally {
// 清除对应的 loading 状态
if (shouldUpdateFeatures) {
setInitialLoading(false)
} else {
setModelsLoading(false)
}
}
}, [])

/**
* 当 endpoint 或 selectedTags 变化时,重新获取数据
*/
useEffect(() => {
if (endpoint) {
// 判断是否是 endpoint 变化
const isEndpointChanged = prevEndpointRef.current !== endpoint
// 更新 ref
if (isEndpointChanged) {
prevEndpointRef.current = endpoint
}

// endpoint 变化时更新 features,仅 tags 变化时不更新 features
fetchAndProcessData(endpoint, "", selectedTags, isEndpointChanged)
}
}, [endpoint, selectedTags, fetchAndProcessData])

/**
* 手动刷新数据
*/
const refetch = useCallback(() => {
if (endpoint) {
fetchAndProcessData(endpoint, "", selectedTags, true)
}
}, [endpoint, selectedTags, fetchAndProcessData])

return {
endpoint: endpointInfo,
features,
models,
initialLoading,
modelsLoading,
error,
refetch,
}
}
175 changes: 170 additions & 5 deletions web_v2/src/app/[locale]/(dashboard)/models/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,176 @@
'use client'
import { TopBar } from "@/components/layout"
export default function ModelsPage() {
import { useLanguage } from "@/components/providers/language-provider"
import { useSidebar } from "@/components/providers"
import { useMemo, useDeferredValue } from "react"
import { ModelFilterPanel } from "@/components/ui/models"
import { useSearchParams } from "next/navigation"
import { useState, useEffect, useCallback } from "react"
import { useEndpointData } from "./hooks/useEndpointData"
import { getInitialEndpoint } from "@/lib/utils"
import { Model } from "@/lib/types/openapi"
import { Loader, AlertCircle } from "lucide-react"
import { ModelCard } from "@/components/ui/modelCard"
import { Button } from "@/components/common/button"

// 标签颜色配置
const tagColors = [
"bg-green-500/10 text-green-500 border-green-500/20",
"bg-blue-500/10 text-blue-500 border-blue-500/20",
"bg-purple-500/10 text-purple-500 border-purple-500/20",
"bg-orange-500/10 text-orange-500 border-orange-500/20",
"bg-pink-500/10 text-pink-500 border-pink-500/20",
"bg-indigo-500/10 text-indigo-500 border-indigo-500/20",
"bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
"bg-red-500/10 text-red-500 border-red-500/20",
]

/**
* 模型目录页面组件
*/
const ModelsPage = () => {
const searchParams = useSearchParams()
const { t } = useLanguage()
const { categoryTrees } = useSidebar()
const [selectedCapability, setSelectedCapability] = useState<string>("")
const [searchQuery, setSearchQuery] = useState("")
const [selectedTags, setSelectedTags] = useState<string[]>([])
// 使用自定义 Hook 获取端点数据
const { features, models, initialLoading, modelsLoading, error, refetch } = useEndpointData(selectedCapability, selectedTags)

// 使用 useDeferredValue 实现搜索防抖,优化大数据量场景下的性能
const deferredSearchQuery = useDeferredValue(searchQuery)

/**
* 根据搜索关键词筛选模型列表
* 使用 deferredSearchQuery 而不是 searchQuery,避免用户快速输入时频繁计算
*/
const filteredModels = useMemo(() => {
if (!models) return []
if (!deferredSearchQuery.trim()) return models

const query = deferredSearchQuery.toLowerCase().trim()
return models.filter((model) => {
// 搜索模型名称
if (model.modelName?.toLowerCase().includes(query)) return true
// 搜索拥有者名称
if (model.ownerName?.toLowerCase().includes(query)) return true
// 搜索端点
if (model.endpoints?.some(ep => ep.toLowerCase().includes(query))) return true
// 搜索特性标签
const modelFeatures = typeof model.features === 'string'
? model.features.split(',').map(f => f.trim())
: model.features || []
if (modelFeatures.some(f => f.toLowerCase().includes(query))) return true
return false
})
}, [models, deferredSearchQuery])

/**
* 初始化选中的能力分类选项 endpoint
*/
useEffect(() => {
const endpoint = getInitialEndpoint(searchParams.get("endpoint"))
setSelectedCapability(endpoint)
}, [searchParams])

/**
* 处理能力分类变化
*/
const handleCapabilityChange = useCallback((endpoint: string) => {
setSelectedCapability(endpoint)
setSelectedTags([])
}, [])

/**
* 处理标签变化
*/
const handleTagsChange = useCallback((tags: string[]) => {
setSelectedTags(tags)
}, [])

/**
* 处理搜索变化
*/
const handleSearchChange = useCallback((query: string) => {
setSearchQuery(query)
}, [])

/**
* 处理添加渠道操作
*/
const handleAddChannel = (model: Model) => {
console.log("添加私有渠道:", model.modelName)
}

return (
<>
<TopBar />
<div className="p-8">
模型目录
<TopBar title={t("modelCatalog")} description={t("modelCatalogDesc")} />
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto">
<div className="container px-6 py-8">
{/* 模型筛选面板 */}
<ModelFilterPanel
categoryTrees={categoryTrees}
features={features}
initialEndpoint={selectedCapability}
initialTags={selectedTags}
isLoadingFeatures={initialLoading}
onCapabilityChange={handleCapabilityChange}
onTagsChange={handleTagsChange}
onSearchChange={handleSearchChange}
/>

{/* 错误提示 */}
{error && (
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 flex-shrink-0 text-red-500" />
<p className="text-sm text-red-500">{error.message}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={refetch}
className="ml-4 flex-shrink-0"
>
{t("retry")}
</Button>
</div>
</div>
)}

{/* 模型列表 */}
<div className="mb-4">
<h2 className="text-sm font-medium text-muted-foreground">
{t("foundModels")} {filteredModels.length} {t("modelsCount")}
</h2>
</div>

{modelsLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader className="h-6 w-6 animate-spin mr-2" />
<span className="text-sm">{t("loadingModels")}</span>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredModels.map((model) => (
<ModelCard
key={model.modelName}
model={model}
tagColors={tagColors}
onAddChannel={handleAddChannel}
/>
))}
</div>
)}
</div>
</div>
</div>
</>
);
)
}

export default ModelsPage

36 changes: 36 additions & 0 deletions web_v2/src/components/common/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)

export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}

export { Badge, badgeVariants }
22 changes: 22 additions & 0 deletions web_v2/src/components/common/input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as React from "react"

import { cn } from "@/lib/utils"

const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"

export { Input }
1 change: 1 addition & 0 deletions web_v2/src/components/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { ThemeProvider, useTheme } from "./theme-provider"
export { LanguageProvider, useLanguage } from "./language-provider"
export { SidebarProvider, useSidebar } from "./sidebar-provider"
Loading