feat: add store categories and items with enhanced filtering functionality

- Introduced new JSON files for store categories and assistant items to improve organization and accessibility.
- Implemented a conversion script to dynamically generate the assistant items list from agents data.
- Refactored store components to utilize the new data structure, enhancing the store layout and user experience.
- Added loading states and error handling for category and item fetching processes.
- Created new GridView and ListView components for displaying store items in different formats.
This commit is contained in:
MyPrototypeWhat 2025-05-13 19:25:37 +08:00
parent 37482bca7b
commit 802402e922
13 changed files with 14454 additions and 321 deletions

View File

@ -0,0 +1,90 @@
[
{
"id": "all",
"title": "Categories",
"items": [
{ "id": "featured", "name": "Featured", "count": 24 },
{ "id": "new", "name": "New Releases", "count": 18 },
{ "id": "top", "name": "Top Rated", "count": 32 }
]
},
{
"id": "assistant",
"title": "助手",
"items": [
{ "id": "assistant-job", "name": "职业" },
{ "id": "assistant-business", "name": "商业" },
{ "id": "assistant-tools", "name": "工具" },
{ "id": "assistant-language", "name": "语言" },
{ "id": "assistant-office", "name": "办公" },
{ "id": "assistant-general", "name": "通用" },
{ "id": "assistant-writing", "name": "写作" },
{ "id": "assistant-coding", "name": "编程" },
{ "id": "assistant-emotion", "name": "情感" },
{ "id": "assistant-education", "name": "教育" },
{ "id": "assistant-creative", "name": "创意" },
{ "id": "assistant-academic", "name": "学术" },
{ "id": "assistant-design", "name": "设计" },
{ "id": "assistant-art", "name": "艺术" },
{ "id": "assistant-entertainment", "name": "娱乐" }
]
},
{
"id": "mini-app",
"title": "小程序",
"items": []
},
{
"id": "knowledge",
"title": "知识库",
"items": [
{ "id": "knowledge-history", "name": "历史" },
{ "id": "knowledge-literature", "name": "文学" },
{ "id": "knowledge-education", "name": "教育" },
{ "id": "knowledge-law", "name": "法律" },
{ "id": "knowledge-science", "name": "科学" },
{ "id": "knowledge-medicine", "name": "医学" },
{ "id": "knowledge-economics", "name": "经济" },
{ "id": "knowledge-art", "name": "艺术" },
{ "id": "knowledge-geography", "name": "地理" },
{ "id": "knowledge-social", "name": "社会" }
]
},
{
"id": "mcp-server",
"title": "MCP 服务器",
"items": [
{ "id": "mcp-dev-tools", "name": "Developer Tools" },
{ "id": "mcp-research-data", "name": "Research And Data" },
{ "id": "mcp-cloud", "name": "Cloud Platforms" },
{ "id": "mcp-communication", "name": "Communication" },
{ "id": "mcp-browser-auto", "name": "Browser Automation" },
{ "id": "mcp-finance", "name": "Finance" },
{ "id": "mcp-security", "name": "Security" },
{ "id": "mcp-os-auto", "name": "Os Automation" },
{ "id": "mcp-databases", "name": "Databases" },
{ "id": "mcp-cloud-storage", "name": "Cloud Storage" },
{ "id": "mcp-monitoring", "name": "Monitoring" },
{ "id": "mcp-media", "name": "Entertainment And Media" },
{ "id": "mcp-knowledge-mem", "name": "Knowledge And Memory" },
{ "id": "mcp-file-systems", "name": "File Systems" },
{ "id": "mcp-location", "name": "Location Services" },
{ "id": "mcp-calendar", "name": "Calendar Management" },
{ "id": "mcp-customer-data", "name": "Customer Data Platforms" },
{ "id": "mcp-ai-chatbot", "name": "AI Chatbot" },
{ "id": "mcp-virtualization", "name": "Virtualization" },
{ "id": "mcp-official-servers", "name": "Official Servers" },
{ "id": "mcp-database", "name": "Database" }
]
},
{
"id": "model-provider",
"title": "模型服务",
"items": []
},
{
"id": "agent",
"title": "智能体",
"items": []
}
]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,124 @@
// convert_agents.js
// 将 agents.json 转换为 list_assistant.json
// 一次性的(如何后面不扩展agents.json), 则不需要再运行这个脚本
const fs = require('fs')
const path = require('path')
// --- 配置路径 ---
const agentsJsonPath = path.resolve(__dirname, '../data/agents.json')
const outputDir = path.resolve(__dirname, '../data')
const outputJsonPath = path.resolve(outputDir, 'list_assistant.json')
// --- 映射和默认值配置 ---
const CATEGORY_ID_ASSISTANT = 'assistant'
// 映射 agents.json 的 group 名称 到 store_categories.json 中 "助手" 分类的二级分类 ID
// Key: agent.group 中的项 (请确保大小写和字符与 agents.json 中的 group 值一致)
// Value: 二级分类 ID (subcategoryId)
const groupToSubcategoryMap = {
职业: 'assistant-job',
商业: 'assistant-business',
工具: 'assistant-tools',
语言: 'assistant-language',
办公: 'assistant-office',
通用: 'assistant-general',
写作: 'assistant-writing',
编程: 'assistant-coding',
情感: 'assistant-emotion',
教育: 'assistant-education',
创意: 'assistant-creative',
学术: 'assistant-academic',
设计: 'assistant-design',
艺术: 'assistant-art',
娱乐: 'assistant-entertainment',
精选: 'assistant-general',
生活: 'assistant-general',
医疗: 'assistant-health',
文案: 'assistant-writing',
健康: 'assistant-health',
点评: 'assistant-review',
百科: 'assistant-knowledge',
旅游: 'assistant-travel',
翻译: 'assistant-language'
}
// 从 agent.group 数组中获取 subcategoryId
// 策略:取第一个在 groupToSubcategoryMap 中能找到匹配的 group 名称
function getSubcategoryIdFromGroup(groupArray = []) {
if (!Array.isArray(groupArray)) return 'assistant-general'
for (const groupName of groupArray) {
const key = String(groupName)
if (groupToSubcategoryMap[key]) {
return groupToSubcategoryMap[key]
}
}
// 如果 group 中没有一项能精确映射,打印警告并返回通用默认值
// (避免为仅包含 "精选" 且 "精选" 本身无特定映射的情况重复打印警告, featured 字段会处理它)
if (!groupArray.includes('精选') || groupArray.length > 1 || !groupToSubcategoryMap['精选']) {
console.warn(
`No specific subcategory mapping found for group: ${JSON.stringify(groupArray)} (excluding '精选' if it has no specific map other than setting featured flag). Defaulting to 'assistant-general'.`
)
}
return 'assistant-general'
}
// --- 主转换逻辑 ---
try {
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
console.log(`Created output directory: ${outputDir}`)
}
const agentsDataRaw = fs.readFileSync(agentsJsonPath, 'utf-8')
const agents = JSON.parse(agentsDataRaw)
// 假设 agents.json 的根是一个直接的数组
if (!Array.isArray(agents)) {
throw new Error(
`agents.json (path: ${agentsJsonPath}) is not an array. Please ensure it is a JSON array of agent objects.`
)
}
console.log(`Read ${agents.length} raw agent objects from ${agentsJsonPath}`)
const storeAssistants = agents
.map((agent) => {
if (!agent || typeof agent.id === 'undefined' || !agent.name) {
console.warn(
'Skipping invalid agent object (missing id or name):',
agent && agent.id ? `ID: ${agent.id}` : agent
)
return null
}
// 从 agent.group 获取 subcategoryId同时将 agent.group 用作 StoreItem.tags
const agentGroups = Array.isArray(agent.group) ? agent.group : []
const subcategoryId = getSubcategoryIdFromGroup(agentGroups)
// 检查 group 是否包含 "精选" 来设置 featured 标志
const isFeaturedByGroup = agentGroups.includes('精选')
return {
id: String(agent.id), // 使用 agent.id (顶层)
title: agent.name, // 使用 agent.name (顶层)
description: agent.description || 'No description available.', // 使用 agent.description (顶层)
type: 'Assistant', // 固定类型
categoryId: CATEGORY_ID_ASSISTANT, // 固定一级分类
subcategoryId: subcategoryId, // 从 agent.group 动态获取
author: 'Cherry Studio', // agent.author 可能不存在, 提供默认 'Cherry Studio'
rating: parseFloat(agent.rating) || 4.0, // agent.rating 可能不存在, 提供默认 4.0
downloads: '0',
image: agent.emoji || '🤖', // 使用 agent.emoji (顶层), 若无则用默认
tags: agentGroups, // 使用 agent.group (顶层) 作为 StoreItem.tags
// 如果 group 含 "精选",则 isFeaturedByGroup 为 true。
featured: isFeaturedByGroup || (typeof agent.featured === 'boolean' ? agent.featured : false)
}
})
.filter((item) => item !== null)
fs.writeFileSync(outputJsonPath, JSON.stringify(storeAssistants, null, 2), 'utf-8')
console.log(`Successfully converted ${storeAssistants.length} agents to ${outputJsonPath}`)
} catch (error) {
console.error('Error during conversion:', error)
process.exit(1)
}

View File

@ -0,0 +1,48 @@
import { CherryStoreItem } from '@renderer/types/cherryStore'
import { Badge } from '@renderer/ui/badge'
import { Button } from '@renderer/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@renderer/ui/card'
import { BlurFade } from '@renderer/ui/third-party/BlurFade'
import { Download, Star } from 'lucide-react'
export function GridView({ items }: { items: CherryStoreItem[] }) {
return (
<div className="grid grid-cols-3 gap-4 ">
{items.map((item) => (
<BlurFade key={item.id} delay={0.25} inView className="overflow-hidden flex flex-col">
<Card className="overflow-hidden p-0 flex flex-col">
<CardHeader className="p-0">
<div className="aspect-square w-full overflow-hidden bg-muted">
<img
src={item.image || '/placeholder.svg'} // Use placeholder if image missing
alt={item.title}
className="h-full w-full object-cover transition-transform hover:scale-105"
/>
</div>
</CardHeader>
<CardContent className="p-4 flex-grow">
<div className="flex items-start justify-between">
<div>
<CardTitle className="line-clamp-1 text-base">{item.title}</CardTitle>
<p className="text-sm text-muted-foreground">{item.author}</p>
</div>
<Badge variant="outline">{item.type}</Badge>
</div>
<p className="mt-2 line-clamp-3 text-sm text-muted-foreground">{item.description}</p>
</CardContent>
<CardFooter className="flex items-center justify-between p-4 pt-0">
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
<span className="text-sm">{item.rating}</span>
</div>
<Button size="sm">
<Download className="mr-2 h-3.5 w-3.5 text-white dark:text-black" />
Install
</Button>
</CardFooter>
</Card>
</BlurFade>
))}
</div>
)
}

View File

@ -0,0 +1,54 @@
import { CherryStoreItem } from '@renderer/types/cherryStore'
import { Badge } from '@renderer/ui/badge'
import { Button } from '@renderer/ui/button'
import { Card } from '@renderer/ui/card'
import { Download, Star } from 'lucide-react'
export function ListView({ items }: { items: CherryStoreItem[] }) {
return (
<div className="space-y-4">
{items.map((item) => (
<Card key={item.id} className="p-0">
<div className="flex flex-col sm:flex-row">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-l-lg bg-muted sm:h-auto">
<img
src={item.image || '/placeholder.svg'} // Use placeholder if image missing
alt={item.title}
className="h-full w-full object-cover"
/>
</div>
<div className="flex flex-1 flex-col justify-between p-4">
<div>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold">{item.title}</h3>
<p className="text-sm text-muted-foreground">{item.author}</p>
</div>
<Badge variant="outline">{item.type}</Badge>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">{item.description}</p>
<div className="mt-2 flex flex-wrap gap-1">
{item.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
</div>
<div className="flex items-center justify-between border-t p-4 sm:w-48 sm:flex-col sm:items-end sm:justify-center sm:border-l sm:border-t-0 sm:p-4">
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
<span className="text-sm">{item.rating}</span>
<span className="text-xs text-muted-foreground">({item.downloads})</span>
</div>
<Button size="sm" className="mt-2">
<Download className="mr-2 h-3.5 w-3.5 text-white dark:text-black" />
Install
</Button>
</div>
</div>
</Card>
))}
</div>
)
}

View File

@ -1,32 +1,19 @@
import { Badge } from '@renderer/ui/badge'
import { CherryStoreItem } from '@renderer/types/cherryStore'
import { Button } from '@renderer/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@renderer/ui/card'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@renderer/ui/dropdown-menu'
import { Input } from '@renderer/ui/input'
import { BlurFade } from '@renderer/ui/third-party/BlurFade'
import { cn } from '@renderer/utils'
import { Download, Filter, Grid3X3, List, Search, Star } from 'lucide-react'
import { Filter, Grid3X3, List, Search } from 'lucide-react'
// Define the type for a store item based on store_list.json
interface StoreItem {
id: number
title: string
description: string
type: string
categoryId: string
subcategoryId: string
author: string
rating: number
downloads: string
image?: string
tags: string[]
featured: boolean
}
import { GridView } from './GridView'
import { ListView } from './ListView'
interface StoreContentProps {
viewMode: 'grid' | 'list'
searchQuery: string
selectedCategory: string
items: StoreItem[]
items: CherryStoreItem[]
onSearchQueryChange: (query: string) => void
onViewModeChange: (mode: 'grid' | 'list') => void
}
@ -39,7 +26,7 @@ export function StoreContent({
onViewModeChange
}: StoreContentProps) {
return (
<div className="flex-1 overflow-auto">
<div className="flex-1 overflow-auto w-full">
{/* Sticky Header for Search, Filter, View Mode, and Category Tabs */}
<div className="sticky top-0 z-10 border-b bg-background p-4">
<div className="flex flex-col gap-4">
@ -97,7 +84,7 @@ export function StoreContent({
</div>
{/* Main Content Area: Grid or List View */}
<div className="p-4">
<div className="p-4 w-full">
{items.length === 0 ? (
<div className="flex h-[400px] items-center justify-center">
<p className="text-center text-muted-foreground">No items found matching your criteria.</p>
@ -112,96 +99,4 @@ export function StoreContent({
)
}
// Grid View Component
function GridView({ items }: { items: StoreItem[] }) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{items.map((item, idx) => (
<BlurFade key={item.id} delay={0.25 + idx * 0.05} inView>
<Card key={item.id} className="overflow-hidden p-0">
<CardHeader className="p-0">
<div className="aspect-square w-full overflow-hidden bg-muted">
<img
src={item.image || '/placeholder.svg'} // Use placeholder if image missing
alt={item.title}
className="h-full w-full object-cover transition-transform hover:scale-105"
/>
</div>
</CardHeader>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div>
<CardTitle className="line-clamp-1 text-base">{item.title}</CardTitle>
<p className="text-sm text-muted-foreground">{item.author}</p>
</div>
<Badge variant="outline">{item.type}</Badge>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">{item.description}</p>
</CardContent>
<CardFooter className="flex items-center justify-between p-4 pt-0">
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
<span className="text-sm">{item.rating}</span>
</div>
<Button size="sm">
<Download className="mr-2 h-3.5 w-3.5 text-white dark:text-black" />
Install
</Button>
</CardFooter>
</Card>
</BlurFade>
))}
</div>
)
}
// List View Component
function ListView({ items }: { items: StoreItem[] }) {
return (
<div className="space-y-4">
{items.map((item) => (
<Card key={item.id} className="p-0">
<div className="flex flex-col sm:flex-row">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-l-lg bg-muted sm:h-auto">
<img
src={item.image || '/placeholder.svg'} // Use placeholder if image missing
alt={item.title}
className="h-full w-full object-cover"
/>
</div>
<div className="flex flex-1 flex-col justify-between p-4">
<div>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold">{item.title}</h3>
<p className="text-sm text-muted-foreground">{item.author}</p>
</div>
<Badge variant="outline">{item.type}</Badge>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">{item.description}</p>
<div className="mt-2 flex flex-wrap gap-1">
{item.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
</div>
<div className="flex items-center justify-between border-t p-4 sm:w-48 sm:flex-col sm:items-end sm:justify-center sm:border-l sm:border-t-0 sm:p-4">
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-primary text-primary" />
<span className="text-sm">{item.rating}</span>
<span className="text-xs text-muted-foreground">({item.downloads})</span>
</div>
<Button size="sm" className="mt-2">
<Download className="mr-2 h-3.5 w-3.5 text-white dark:text-black" />
Install
</Button>
</div>
</div>
</Card>
))}
</div>
)
}

View File

@ -10,23 +10,36 @@ import {
SidebarMenuItem
} from '@renderer/ui/sidebar'
import categories from '../data/store_categories.json'
import { Category } from '../data'
interface StoreSidebarProps {
categories: Category[]
selectedCategory: string
selectedSubcategory: string
onSelectCategory: (categoryId: string, subcategoryId: string) => void
}
export function StoreSidebar({ selectedCategory, selectedSubcategory, onSelectCategory }: StoreSidebarProps) {
console.log('selectedCategory', selectedCategory)
console.log('selectedSubcategory', selectedSubcategory)
export function StoreSidebar({
categories,
selectedCategory,
selectedSubcategory,
onSelectCategory
}: StoreSidebarProps) {
if (!categories || categories.length === 0) {
return (
<Sidebar className="absolute left-0 top-0 h-full border-r">
<SidebarContent>
<p className="p-4 text-sm text-gray-500">No categories loaded.</p>
</SidebarContent>
</Sidebar>
)
}
return (
<Sidebar className="absolute left-0 top-0 h-full border-r">
<SidebarContent>
{categories.map((category) => (
<SidebarGroup key={category.title}>
{/* Only render label if it's not the 'all' category wrapper */}
<SidebarGroup key={category.id}>
{category.id !== 'all' && <SidebarGroupLabel>{category.title}</SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
@ -36,14 +49,14 @@ export function StoreSidebar({ selectedCategory, selectedSubcategory, onSelectCa
isActive={category.id === selectedCategory && item.id === selectedSubcategory}
className="justify-between"
onClick={() => {
console.log('category', category)
// Special handling for top-level 'all' categories vs nested ones
onSelectCategory(category.id, item.id) // Selecting 'all', 'featured', 'new', 'top' uses the item.id as category
onSelectCategory(category.id, item.id)
}}>
{item.name}
<Badge variant="secondary" className="ml-auto">
{item.count}
</Badge>
{typeof item.count === 'number' && (
<Badge variant="secondary" className="ml-auto">
{item.count}
</Badge>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}

View File

@ -0,0 +1,168 @@
import store from '@renderer/store'
import { CherryStoreItem } from '@renderer/types/cherryStore'
// 定义 Category 和 SubCategory 的类型 (基于您 store_categories.json 的结构)
// 您可能已经在别处定义了这些类型,如果是,请使用它们。
export interface SubCategoryItem {
id: string
name: string
count?: number // count 是可选的,因为并非所有二级分类都有
isActive?: boolean
}
export interface Category {
id: string
title: string
items: SubCategoryItem[]
}
// 移除 LoadedStoreData 和 LoadedStoreDataByType因为我们将按需加载
// export interface LoadedStoreData {
// categories: Category[]
// allItems: CherryStoreItem[]
// }
// export interface LoadedStoreDataByType {
// categories: Category[]
// assistantItems?: CherryStoreItem[]
// knowledgeItems?: CherryStoreItem[] // Example for another type
// mcpServerItems?: CherryStoreItem[] // Example for another type
// // Add other item types here as you create their list_*.json files
// }
const getResourcesPath = (path: string) => {
const { resourcesPath } = store.getState().runtime
return resourcesPath + path
}
// 缓存变量
let cachedCategories: Category[] | null = null
const cachedItemsByFile: Record<string, CherryStoreItem[]> = {}
// Helper function to read and parse JSON files safely
async function readFileSafe<T>(filePath: string): Promise<T | undefined> {
try {
if (!window.api?.fs?.read) {
console.error('window.api.fs.read is not available. Ensure preload script is set up correctly.')
return undefined
}
const fileContent = await window.api.fs.read(filePath)
if (typeof fileContent === 'string') {
return JSON.parse(fileContent) as T
}
console.warn(
`Content read from ${filePath} was not a string or file might be empty/missing. Received:`,
fileContent
)
return undefined
} catch (error) {
console.error(`Error reading or parsing file ${filePath}:`, error)
return undefined
}
}
export async function loadCategories(resourcesPath: string): Promise<Category[]> {
if (cachedCategories) {
console.log('Returning cached categories:', cachedCategories.length)
return cachedCategories
}
const categoriesFilePath = resourcesPath + '/data/store_categories.json'
console.log('categoriesFilePath', categoriesFilePath)
const categories = (await readFileSafe<Category[]>(categoriesFilePath)) || []
if (categories.length > 0) {
cachedCategories = categories
console.log('Categories loaded and cached:', categories.length)
} else {
console.log('No categories found or error loading categories.')
}
return categories
}
// 新函数:根据分类、子分类和搜索查询加载和筛选商品
export async function loadAndFilterItems(
categoryId: string,
subcategoryId: string,
searchQuery: string
): Promise<CherryStoreItem[]> {
let itemsFilePath = ''
if (categoryId === 'all' || !categoryId) {
console.warn("loadAndFilterItems called with 'all' or invalid categoryId. Returning empty for now.")
return []
} else {
itemsFilePath = getResourcesPath(`/data/store_list_${categoryId}.json`)
}
if (!itemsFilePath) {
console.error(`No item file path determined for categoryId: ${categoryId}`)
return []
}
let items: CherryStoreItem[] = []
if (cachedItemsByFile[itemsFilePath]) {
items = cachedItemsByFile[itemsFilePath]
console.log(`Returning cached items for ${itemsFilePath}:`, items.length)
} else {
const loadedItems = await readFileSafe<CherryStoreItem[]>(itemsFilePath)
if (loadedItems) {
items = loadedItems
cachedItemsByFile[itemsFilePath] = loadedItems
console.log(`Items loaded and cached for ${itemsFilePath}:`, items.length)
} else {
console.log(`No items found or error loading items for: ${itemsFilePath}`)
// 确保在文件读取失败或为空时items 仍然是空数组
items = []
// 也可以选择缓存一个空数组,以避免重复尝试读取不存在或错误的文件
// cachedItemsByFile[itemsFilePath] = [];
}
}
if (!items.length) {
// 如果缓存中是空数组或者新加载的是空数组,直接返回,避免不必要的筛选
return []
}
let filteredItems = items
if (subcategoryId) {
filteredItems = filteredItems.filter((item) => {
return item.subcategoryId === subcategoryId
})
}
if (searchQuery) {
const query = searchQuery.toLowerCase()
filteredItems = filteredItems.filter((item) => {
const searchableText = `${item.title.toLowerCase()} ${item.description?.toLowerCase() || ''} ${item.author?.toLowerCase() || ''} ${item.tags?.join(' ')?.toLowerCase() || ''}`
return searchableText.includes(query)
})
}
console.log(
`Filtered items for ${categoryId} - ${subcategoryId} - "${searchQuery}". Found: ${filteredItems.length} (from ${items.length} initial)`
)
return filteredItems
}
// 原始的 loadStoreDataSeparately 现在可以移除或重构
// 如果还需要一次性加载所有数据(例如用于初始全局搜索或特定情况),可以保留并调整
// 但根据需求,我们现在倾向于按需加载
/*
export async function loadStoreDataSeparately(): Promise<LoadedStoreDataByType> {
const categories = (await readFileSafe<Category[]>(getResourcesPath('/data/store_categories.json'))) || []
// 示例: 不再默认加载所有类型的 items
// const assistantItems = await readFileSafe<CherryStoreItem[]>(getResourcesPath('/data/store_list_assistant.json'))
console.log('Store data loaded (categories only by default):', {
categoriesCount: categories.length,
})
return {
categories,
// assistantItems: undefined, // Items 会按需加载
// knowledgeItems: undefined,
// mcpServerItems: undefined,
}
}
*/

View File

@ -1,42 +0,0 @@
[
{
"id": "all",
"title": "Categories",
"items": [
{ "id": "all", "name": "All", "count": 120, "isActive": true },
{ "id": "featured", "name": "Featured", "count": 24 },
{ "id": "new", "name": "New Releases", "count": 18 },
{ "id": "top", "name": "Top Rated", "count": 32 }
]
},
{
"id": "mcp",
"title": "MCP Services",
"items": [
{ "id": "mcp-text", "name": "Text Generation", "count": 15 },
{ "id": "mcp-image", "name": "Image Generation", "count": 8 },
{ "id": "mcp-audio", "name": "Audio Processing", "count": 6 },
{ "id": "mcp-code", "name": "Code Assistance", "count": 12 }
]
},
{
"id": "plugins",
"title": "Plugins",
"items": [
{ "id": "plugin-productivity", "name": "Productivity", "count": 14 },
{ "id": "plugin-development", "name": "Development", "count": 22 },
{ "id": "plugin-design", "name": "Design", "count": 9 },
{ "id": "plugin-utilities", "name": "Utilities", "count": 18 }
]
},
{
"id": "apps",
"title": "Applications",
"items": [
{ "id": "app-desktop", "name": "Desktop", "count": 7 },
{ "id": "app-web", "name": "Web", "count": 11 },
{ "id": "app-mobile", "name": "Mobile", "count": 5 },
{ "id": "app-cli", "name": "CLI", "count": 8 }
]
}
]

View File

@ -1,114 +0,0 @@
[
{
"id": 1,
"title": "GPT-4 Turbo",
"description": "Advanced language model with improved reasoning capabilities",
"type": "MCP Service",
"categoryId": "mcp",
"subcategoryId": "mcp-text",
"author": "OpenAI",
"rating": 4.9,
"downloads": "1.2M",
"image": "/placeholder.svg?height=200&width=200",
"tags": ["Text Generation", "Featured"],
"featured": true
},
{
"id": 2,
"title": "Claude 3 Opus",
"description": "High-performance model for complex reasoning and content generation",
"type": "MCP Service",
"categoryId": "mcp",
"subcategoryId": "mcp-text",
"author": "Anthropic",
"rating": 4.8,
"downloads": "850K",
"image": "/placeholder.svg?height=200&width=200",
"tags": ["Text Generation", "Featured"],
"featured": true
},
{
"id": 3,
"title": "Midjourney Connect",
"description": "Integration plugin for Midjourney image generation",
"type": "Plugin",
"categoryId": "plugins",
"subcategoryId": "plugin-design",
"author": "Cherry Studio",
"rating": 4.7,
"downloads": "620K",
"image": "/placeholder.svg?height=200&width=200",
"tags": ["Image Generation", "Design"],
"featured": false
},
{
"id": 4,
"title": "Code Interpreter",
"description": "Execute and analyze code within your conversations",
"type": "Plugin",
"categoryId": "plugins",
"subcategoryId": "plugin-development",
"author": "Cherry Studio",
"rating": 4.9,
"downloads": "1.5M",
"image": "/placeholder.svg?height=200&width=200",
"tags": ["Development", "Code Assistance", "Featured"],
"featured": true
},
{
"id": 5,
"title": "Voice Assistant",
"description": "Add voice interaction capabilities to Cherry Studio",
"type": "Application",
"categoryId": "apps",
"subcategoryId": "app-desktop",
"author": "Cherry Audio",
"rating": 4.6,
"downloads": "780K",
"image": "/placeholder.svg?height=200&width=200",
"tags": ["Audio Processing", "Desktop"],
"featured": false
},
{
"id": 6,
"title": "Stable Diffusion XL",
"description": "High-quality image generation model",
"type": "MCP Service",
"categoryId": "mcp",
"subcategoryId": "mcp-image",
"author": "Stability AI",
"rating": 4.8,
"downloads": "920K",
"image": "/placeholder.svg?height=200&width=200",
"tags": ["Image Generation", "Featured"],
"featured": true
},
{
"id": 7,
"title": "Knowledge Base",
"description": "Create and manage custom knowledge bases for your LLMs",
"type": "Plugin",
"categoryId": "plugins",
"subcategoryId": "plugin-utilities",
"author": "Cherry Studio",
"rating": 4.7,
"downloads": "540K",
"image": "/placeholder.svg?height=200&width=200",
"tags": ["Productivity", "Utilities"],
"featured": false
},
{
"id": 8,
"title": "Workflow Automator",
"description": "Create automated workflows with LLMs and other tools",
"type": "Application",
"categoryId": "apps",
"subcategoryId": "app-desktop",
"author": "Cherry Automation",
"rating": 4.5,
"downloads": "320K",
"image": "/placeholder.svg?height=200&width=200",
"tags": ["Productivity", "Desktop", "Web"],
"featured": false
}
]

View File

@ -0,0 +1,55 @@
import { CherryStoreItem } from '@renderer/types/cherryStore'
import { useMemo } from 'react'
// 假设 Item 类型定义,您可以从 store_list.json 的结构推断或在项目中共享
// 如果没有明确的类型定义,可以使用 any但强烈建议定义类型
export function useFilteredStoreItems(
storeList: CherryStoreItem[],
searchQuery: string,
selectedCategory: string,
selectedSubcategory: string
) {
return useMemo(() => {
if (!storeList) {
return []
}
return storeList.filter((item) => {
const lowerSearchQuery = searchQuery.toLowerCase()
const matchesSearch =
searchQuery === '' ||
item.title.toLowerCase().includes(lowerSearchQuery) ||
item.description.toLowerCase().includes(lowerSearchQuery) ||
item.author.toLowerCase().includes(lowerSearchQuery) ||
item.tags.some((tag) => tag.toLowerCase().includes(lowerSearchQuery))
let matchesCategory = false
if (selectedCategory === 'all') {
matchesCategory = true
} else if (['featured', 'new', 'top'].includes(selectedCategory)) {
// 当前的筛选逻辑中 'featured', 'new', 'top' 是特殊处理的
// 'new' 和 'top' 还没有具体的实现逻辑,这里保持和原组件一致
if (selectedCategory === 'featured') {
matchesCategory = item.featured === true
} else {
// 如果 selectedCategory 是 'new' 或 'top' 但不是 'featured'
// 并且 item 没有 .featured = true, 那么 matchesCategory 仍然是 false
// 这可能需要根据实际需求调整,例如:
// if (selectedCategory === 'new') matchesCategory = item.isNew === true; (假设有 isNew 字段)
// if (selectedCategory === 'top') matchesCategory = item.isTop === true; (假设有 isTop 字段)
// 为了保持和原逻辑一致,这里暂时不修改这部分行为,但提示您可能需要完善
matchesCategory = item.featured === true && ['featured'].includes(selectedCategory) // 或者更复杂的逻辑
}
} else {
matchesCategory = item.categoryId === selectedCategory
}
const matchesSubcategory =
['all', 'featured', 'new', 'top'].includes(selectedCategory) || // If a special category is selected, subcategory filter might be bypassed or handled differently
selectedSubcategory === 'all' ||
item.subcategoryId === selectedSubcategory
return matchesSearch && matchesCategory && matchesSubcategory
})
}, [storeList, searchQuery, selectedCategory, selectedSubcategory])
}

View File

@ -1,58 +1,83 @@
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { CherryStoreItem } from '@renderer/types/cherryStore'
import { SidebarProvider } from '@renderer/ui/sidebar'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StoreContent } from './components/StoreContent'
import { StoreSidebar } from './components/StoreSidebar'
import storeList from './data/store_list.json'
import { Category, loadAndFilterItems, loadCategories } from './data'
export default function StoreLayout() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState<string>('all')
const [selectedCategory, setSelectedCategory] = useState<string>('')
const [selectedSubcategory, setSelectedSubcategory] = useState<string>('all')
const { t } = useTranslation()
const { resourcesPath } = useRuntime()
const filteredItems = storeList.filter((item) => {
const matchesSearch =
searchQuery === '' ||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.author.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()))
const [categories, setCategories] = useState<Category[]>([])
const [items, setItems] = useState<CherryStoreItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isLoadingItems, setIsLoadingItems] = useState(false)
const [error, setError] = useState<string | null>(null)
let matchesCategory = false
if (selectedCategory === 'all') {
matchesCategory = true
} else if (['featured', 'new', 'top'].includes(selectedCategory)) {
if (selectedCategory === 'featured') {
matchesCategory = item.featured === true
useEffect(() => {
const fetchCategories = async () => {
try {
setIsLoading(true)
setError(null)
const loadedCategories = await loadCategories(resourcesPath)
setCategories(loadedCategories)
if (loadedCategories.length > 0 && !selectedCategory) {
setSelectedCategory(loadedCategories[0].id)
}
} catch (err) {
console.error('Error in StoreLayout fetchCategories:', err)
setError('Failed to load store categories. Check console for details.')
} finally {
setIsLoading(false)
}
} else {
matchesCategory = item.categoryId === selectedCategory
}
fetchCategories()
}, [resourcesPath])
useEffect(() => {
if (!selectedCategory) {
setItems([])
return
}
const matchesSubcategory =
['all', 'featured', 'new', 'top'].includes(selectedCategory) ||
selectedSubcategory === 'all' ||
item.subcategoryId === selectedSubcategory
const fetchItems = async () => {
try {
setIsLoadingItems(true)
setError(null)
const filteredItems = await loadAndFilterItems(selectedCategory, selectedSubcategory, searchQuery)
setItems(filteredItems)
} catch (err) {
console.error('Error in StoreLayout fetchItems:', err)
setError('Failed to load store items. Check console for details.')
setItems([])
} finally {
setIsLoadingItems(false)
}
}
return matchesSearch && matchesCategory && matchesSubcategory
})
fetchItems()
}, [selectedCategory, selectedSubcategory, searchQuery])
const handleSelectCategory = (categoryId: string, subcategoryId: string) => {
console.log('categoryId', categoryId)
console.log('subcategoryId', subcategoryId)
setSelectedCategory(categoryId)
setSelectedSubcategory(subcategoryId)
// setSelectedSubcategory('all')
}
// const handleTabCategoryChange = (categoryId: string) => {
// setSelectedCategory(categoryId)
// setSelectedSubcategory('all')
// }
if (isLoading) {
return <div className="p-4 text-center">Loading store categories...</div>
}
if (error) {
return <div className="p-4 text-red-500 text-center">Error: {error}</div>
}
return (
<div className="h-[calc(100vh_-_var(--navbar-height))] w-full">
@ -62,18 +87,23 @@ export default function StoreLayout() {
<div id="content-container" className="h-full w-full">
<SidebarProvider className="h-full w-full relative min-h-full">
<StoreSidebar
categories={categories}
selectedCategory={selectedCategory}
selectedSubcategory={selectedSubcategory}
onSelectCategory={handleSelectCategory}
/>
<StoreContent
viewMode={viewMode}
searchQuery={searchQuery}
selectedCategory={selectedCategory}
items={filteredItems}
onSearchQueryChange={setSearchQuery}
onViewModeChange={setViewMode}
/>
{isLoadingItems ? (
<div className="p-4 text-center">Loading items...</div>
) : (
<StoreContent
viewMode={viewMode}
searchQuery={searchQuery}
selectedCategory={selectedCategory}
items={items}
onSearchQueryChange={setSearchQuery}
onViewModeChange={setViewMode}
/>
)}
</SidebarProvider>
</div>
</div>

View File

@ -0,0 +1,14 @@
export interface CherryStoreItem {
id: string
title: string
description: string
type: string
categoryId: string
subcategoryId: string
author: string
rating: number
downloads: string
image: string
tags: string[]
featured?: boolean
}