feat(json-render): 添加小说列表相关组件
- 添加 novel-item 小说卡片组件,支持封面、VIP、状态、标签 - 添加 count 计数显示组件,支持多种样式变体 - 添加 refresh-button 刷新按钮,支持加载状态 - 添加 filters 筛选器容器组件 - 添加 header 头部组件,支持标题和操作区 - 添加 category-filter 分类筛选组件,支持单选和计数 - 添加 search-input 搜索输入组件 - 添加 search-bar 搜索栏组件,支持清除按钮 - 添加 filter-section 筛选区域容器,支持折叠 - 添加 novel-grid 网格布局组件,支持响应式 - 添加 pagination 分页组件,支持首尾页和省略号 - 添加 search-section 搜索区域容器 - 增强 novel-list 支持 children 模式和空状态消息 共计 12 个新组件,支持完整的小说列表界面渲染 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -103,7 +103,9 @@ export const NovelListProps = z.object({
|
||||
description: z.string().optional(),
|
||||
chapterCount: z.number().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
})),
|
||||
})).optional(),
|
||||
children: z.array(z.any()).optional(),
|
||||
emptyMessage: z.string().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -150,6 +152,114 @@ export const SuggestionButtonsProps = z.object({
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============ Novel 相关组件 ============
|
||||
|
||||
export const NovelItemProps = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
title: z.string(),
|
||||
author: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
chapterCount: z.number().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
coverUrl: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
isVip: z.boolean().optional(),
|
||||
onClick: z.string().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CountProps = z.object({
|
||||
value: z.number(),
|
||||
label: z.string().optional(),
|
||||
variant: z.enum(['default', 'primary', 'success', 'warning']).optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const RefreshButtonProps = z.object({
|
||||
label: z.string().optional(),
|
||||
loading: z.boolean().optional(),
|
||||
onClick: z.string().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
variant: z.enum(['default', 'primary']).optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============ 筛选器相关组件 ============
|
||||
|
||||
export const FiltersProps = z.object({
|
||||
children: z.array(z.any()).optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const HeaderProps = z.object({
|
||||
title: z.string().optional(),
|
||||
subtitle: z.string().optional(),
|
||||
actions: z.array(z.any()).optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CategoryFilterProps = z.object({
|
||||
categories: z.array(z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
count: z.number().optional(),
|
||||
})).optional(),
|
||||
selected: z.union([z.string(), z.number()]).optional(),
|
||||
onSelect: z.string().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SearchInputProps = z.object({
|
||||
placeholder: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
onSearch: z.string().optional(),
|
||||
onInputChange: z.string().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============ 缺失组件 ============
|
||||
|
||||
export const SearchBarProps = z.object({
|
||||
placeholder: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
onSearch: z.string().optional(),
|
||||
onClear: z.string().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
showClearButton: z.boolean().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const FilterSectionProps = z.object({
|
||||
title: z.string().optional(),
|
||||
children: z.array(z.any()).optional(),
|
||||
collapsible: z.boolean().optional(),
|
||||
defaultCollapsed: z.boolean().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const NovelGridProps = z.object({
|
||||
columns: z.number().optional(),
|
||||
gap: z.number().optional(),
|
||||
children: z.array(z.any()).optional(),
|
||||
responsive: z.boolean().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const PaginationProps = z.object({
|
||||
currentPage: z.number(),
|
||||
totalPages: z.number(),
|
||||
onPageChange: z.string().optional(),
|
||||
showFirstLast: z.boolean().optional(),
|
||||
maxVisiblePages: z.number().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SearchSectionProps = z.object({
|
||||
children: z.array(z.any()).optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
// 创建 catalog
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
@@ -225,6 +335,54 @@ const catalog = defineCatalog(schema, {
|
||||
props: SuggestionButtonsProps,
|
||||
description: '建议按钮组组件,用于快速选择预定义消息',
|
||||
},
|
||||
'novel-item': {
|
||||
props: NovelItemProps,
|
||||
description: '小说列表项组件,用于展示单个小说的卡片',
|
||||
},
|
||||
count: {
|
||||
props: CountProps,
|
||||
description: '计数显示组件,用于显示数量统计',
|
||||
},
|
||||
'refresh-button': {
|
||||
props: RefreshButtonProps,
|
||||
description: '刷新按钮组件,支持加载状态',
|
||||
},
|
||||
filters: {
|
||||
props: FiltersProps,
|
||||
description: '筛选器容器组件,用于包装筛选相关元素',
|
||||
},
|
||||
header: {
|
||||
props: HeaderProps,
|
||||
description: '头部组件,用于页面标题和操作区',
|
||||
},
|
||||
'category-filter': {
|
||||
props: CategoryFilterProps,
|
||||
description: '分类筛选组件,支持单选分类',
|
||||
},
|
||||
'search-input': {
|
||||
props: SearchInputProps,
|
||||
description: '搜索输入组件,支持搜索和输入事件',
|
||||
},
|
||||
'search-bar': {
|
||||
props: SearchBarProps,
|
||||
description: '搜索栏组件,包含搜索输入框和搜索按钮',
|
||||
},
|
||||
'filter-section': {
|
||||
props: FilterSectionProps,
|
||||
description: '筛选区域容器组件,用于包装分类筛选等筛选元素',
|
||||
},
|
||||
'novel-grid': {
|
||||
props: NovelGridProps,
|
||||
description: '网格布局容器组件,用于展示小说卡片',
|
||||
},
|
||||
'pagination': {
|
||||
props: PaginationProps,
|
||||
description: '分页组件,显示页码按钮和翻页控制',
|
||||
},
|
||||
'search-section': {
|
||||
props: SearchSectionProps,
|
||||
description: '搜索区域容器组件,用于包装搜索输入和刷新按钮',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,18 @@ import catalog, {
|
||||
LoginPanelProps,
|
||||
McpStatusProps,
|
||||
SuggestionButtonsProps,
|
||||
NovelItemProps,
|
||||
CountProps,
|
||||
RefreshButtonProps,
|
||||
FiltersProps,
|
||||
HeaderProps,
|
||||
CategoryFilterProps,
|
||||
SearchInputProps,
|
||||
SearchBarProps,
|
||||
FilterSectionProps,
|
||||
NovelGridProps,
|
||||
PaginationProps,
|
||||
SearchSectionProps,
|
||||
} from './catalog/catalog';
|
||||
|
||||
// ============ 基础组件 ============
|
||||
@@ -252,31 +264,42 @@ const TranslationResult = ({ props }: BaseComponentProps<z.infer<typeof Translat
|
||||
);
|
||||
};
|
||||
|
||||
const NovelList = ({ props, emit }: BaseComponentProps<z.infer<typeof NovelListProps>>) => (
|
||||
const NovelList = ({ props, emit, children }: BaseComponentProps<z.infer<typeof NovelListProps>>) => (
|
||||
<div className={`space-y-3 ${props.className || ''}`}>
|
||||
{props.novels?.map((novel) => (
|
||||
<div
|
||||
key={String(novel.id)}
|
||||
onClick={() => emit('selectNovel')}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 p-4 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all cursor-pointer"
|
||||
>
|
||||
<h4 className="font-semibold text-lg text-gray-900 dark:text-white mb-1">{novel.title}</h4>
|
||||
{novel.author && <p className="text-sm text-gray-500 dark:text-gray-400 mb-2">By {novel.author}</p>}
|
||||
{novel.description && <p className="text-sm text-gray-600 dark:text-gray-300 mb-2 line-clamp-2">{novel.description}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
{novel.chapterCount && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">
|
||||
{novel.chapterCount} chapters
|
||||
</span>
|
||||
)}
|
||||
{novel.tags?.map((tag) => (
|
||||
<span key={tag} className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{/* 如果有 children,直接渲染 children */}
|
||||
{children && children.length > 0 ? (
|
||||
children
|
||||
) : props.novels && props.novels.length > 0 ? (
|
||||
// 否则渲染 novels 数组
|
||||
props.novels.map((novel) => (
|
||||
<div
|
||||
key={String(novel.id)}
|
||||
onClick={() => emit('selectNovel')}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 p-4 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all cursor-pointer"
|
||||
>
|
||||
<h4 className="font-semibold text-lg text-gray-900 dark:text-white mb-1">{novel.title}</h4>
|
||||
{novel.author && <p className="text-sm text-gray-500 dark:text-gray-400 mb-2">By {novel.author}</p>}
|
||||
{novel.description && <p className="text-sm text-gray-600 dark:text-gray-300 mb-2 line-clamp-2">{novel.description}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
{novel.chapterCount && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">
|
||||
{novel.chapterCount} chapters
|
||||
</span>
|
||||
)}
|
||||
{novel.tags?.map((tag) => (
|
||||
<span key={tag} className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
// 空状态
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{props.emptyMessage || '暂无小说'}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -443,6 +466,469 @@ const SuggestionButtons = ({ props }: BaseComponentProps<z.infer<typeof Suggesti
|
||||
);
|
||||
};
|
||||
|
||||
// ============ Novel 相关组件实现 ============
|
||||
|
||||
const NovelItem = ({ props, emit }: BaseComponentProps<z.infer<typeof NovelItemProps>>) => {
|
||||
const handleClick = () => {
|
||||
if (props.onClick) {
|
||||
emit(props.onClick);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={`bg-white dark:bg-gray-800 rounded-lg border dark:border-gray-700 p-4 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all cursor-pointer ${props.className || ''}`}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{props.coverUrl && (
|
||||
<div className="flex-shrink-0 w-24 h-32 rounded-md overflow-hidden bg-gray-200 dark:bg-gray-700">
|
||||
<img src={props.coverUrl} alt={props.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<h4 className="font-semibold text-lg text-gray-900 dark:text-white truncate">{props.title}</h4>
|
||||
{props.isVip && (
|
||||
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs rounded-full font-medium">
|
||||
👑 VIP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{props.author && <p className="text-sm text-gray-500 dark:text-gray-400 mb-1">By {props.author}</p>}
|
||||
{props.description && <p className="text-sm text-gray-600 dark:text-gray-300 mb-2 line-clamp-2">{props.description}</p>}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{props.status && (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${
|
||||
props.status === 'completed' ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200' :
|
||||
props.status === 'ongoing' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200' :
|
||||
'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200'
|
||||
}`}>
|
||||
{props.status === 'completed' ? '已完结' : props.status === 'ongoing' ? '连载中' : props.status}
|
||||
</span>
|
||||
)}
|
||||
{props.chapterCount && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">
|
||||
{props.chapterCount} 章
|
||||
</span>
|
||||
)}
|
||||
{props.tags?.map((tag) => (
|
||||
<span key={tag} className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Count = ({ props }: BaseComponentProps<z.infer<typeof CountProps>>) => {
|
||||
const variantClasses: Record<string, string> = {
|
||||
default: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200',
|
||||
primary: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200',
|
||||
success: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200',
|
||||
warning: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200',
|
||||
};
|
||||
const variant = props.variant || 'default';
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg ${variantClasses[variant]} ${props.className || ''}`}>
|
||||
{props.label && <span className="text-sm">{props.label}:</span>}
|
||||
<span className="text-lg font-semibold">{props.value}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RefreshButton = ({ props, emit }: BaseComponentProps<z.infer<typeof RefreshButtonProps>>) => {
|
||||
const handleClick = () => {
|
||||
if (!props.disabled && !props.loading && props.onClick) {
|
||||
emit(props.onClick);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={props.disabled || props.loading}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
props.variant === 'primary'
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-800 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-200'
|
||||
} ${props.className || ''}`}
|
||||
>
|
||||
{props.loading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>加载中...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>{props.label || '刷新'}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ 筛选器相关组件实现 ============
|
||||
|
||||
const Filters = ({ props, children }: BaseComponentProps<z.infer<typeof FiltersProps>>) => (
|
||||
<div className={`flex flex-wrap items-center gap-3 ${props.className || ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Header = ({ props, children }: BaseComponentProps<z.infer<typeof HeaderProps>>) => (
|
||||
<div className={`mb-6 ${props.className || ''}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
{props.title && <h1 className="text-2xl font-bold text-gray-900 dark:text-white">{props.title}</h1>}
|
||||
{props.subtitle && <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{props.subtitle}</p>}
|
||||
</div>
|
||||
{props.actions && props.actions.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{props.actions.map((action: any, i: number) => (
|
||||
<span key={i}>{action}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CategoryFilter = ({ props, emit }: BaseComponentProps<z.infer<typeof CategoryFilterProps>>) => {
|
||||
const selected = props.selected;
|
||||
|
||||
const handleSelect = (category: any) => {
|
||||
if (props.onSelect) {
|
||||
emit(props.onSelect);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-2 ${props.className || ''}`}>
|
||||
{props.categories?.map((category) => {
|
||||
const isSelected = selected === category.id;
|
||||
return (
|
||||
<button
|
||||
key={String(category.id)}
|
||||
onClick={() => handleSelect(category)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{category.name}
|
||||
{category.count !== undefined && (
|
||||
<span className={`ml-1.5 ${isSelected ? 'text-blue-200' : 'text-gray-500'}`}>
|
||||
({category.count})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchInput = ({ props, emit }: BaseComponentProps<z.infer<typeof SearchInputProps>>) => {
|
||||
const [value, setValue] = useState(props.value || '');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (props.onSearch) {
|
||||
emit(props.onSearch);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (newValue: string) => {
|
||||
setValue(newValue);
|
||||
if (props.onInputChange) {
|
||||
emit(props.onInputChange);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${props.className || ''}`}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={props.placeholder || '搜索...'}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={props.disabled}
|
||||
className="w-full pl-10 pr-12 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={props.disabled || !value.trim()}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 dark:disabled:bg-gray-600 text-white text-sm rounded-md transition-colors disabled:cursor-not-allowed"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ 缺失组件实现 ============
|
||||
|
||||
const SearchBar = ({ props, emit }: BaseComponentProps<z.infer<typeof SearchBarProps>>) => {
|
||||
const [value, setValue] = useState(props.value || '');
|
||||
|
||||
const handleSearch = () => {
|
||||
if (props.onSearch && value.trim()) {
|
||||
emit(props.onSearch);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setValue('');
|
||||
if (props.onClear) {
|
||||
emit(props.onClear);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const showClear = props.showClearButton !== false && value.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${props.className || ''}`}>
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={props.placeholder || '搜索小说...'}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={props.disabled}
|
||||
className="w-full pl-10 pr-24 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
||||
{showClear && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={props.disabled}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={props.disabled || !value.trim()}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 dark:disabled:bg-gray-600 text-white text-sm rounded-md transition-colors disabled:cursor-not-allowed"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchSection = ({ props, children }: BaseComponentProps<z.infer<typeof SearchSectionProps>>) => (
|
||||
<div className={`flex items-center gap-3 mb-6 ${props.className || ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const FilterSection = ({ props, children }: BaseComponentProps<z.infer<typeof FilterSectionProps>>) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(props.defaultCollapsed || false);
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 ${props.className || ''}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
{props.title && (
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{props.title}</h3>
|
||||
)}
|
||||
{props.collapsible && (
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isCollapsed ? '-rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NovelGrid = ({ props, children }: BaseComponentProps<z.infer<typeof NovelGridProps>>) => {
|
||||
const columns = props.columns || 3;
|
||||
const gap = props.gap || 4;
|
||||
|
||||
// 响应式布局
|
||||
const gridClass = props.responsive !== false
|
||||
? `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-${columns}`
|
||||
: `grid grid-cols-${columns}`;
|
||||
|
||||
const gapClass = gap === 1 ? 'gap-1'
|
||||
: gap === 2 ? 'gap-2'
|
||||
: gap === 3 ? 'gap-3'
|
||||
: gap === 4 ? 'gap-4'
|
||||
: gap === 6 ? 'gap-6'
|
||||
: `gap-[${gap}px]`;
|
||||
|
||||
return (
|
||||
<div className={`${gridClass} ${gapClass} ${props.className || ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Pagination = ({ props, emit }: BaseComponentProps<z.infer<typeof PaginationProps>>) => {
|
||||
const currentPage = props.currentPage;
|
||||
const totalPages = props.totalPages;
|
||||
const maxVisible = props.maxVisiblePages || 5;
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const showFirstLast = props.showFirstLast !== false;
|
||||
|
||||
if (showFirstLast && currentPage > 1) {
|
||||
pages.push('first');
|
||||
}
|
||||
|
||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));
|
||||
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
|
||||
|
||||
if (endPage - startPage + 1 < maxVisible) {
|
||||
startPage = Math.max(1, endPage - maxVisible + 1);
|
||||
}
|
||||
|
||||
if (startPage > 1) {
|
||||
pages.push(1);
|
||||
if (startPage > 2) pages.push('...');
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) pages.push('...');
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
if (showFirstLast && currentPage < totalPages) {
|
||||
pages.push('last');
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages && page !== currentPage && props.onPageChange) {
|
||||
emit(props.onPageChange);
|
||||
}
|
||||
};
|
||||
|
||||
const pages = getPageNumbers();
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center gap-1 ${props.className || ''}`}>
|
||||
{pages.map((page, idx) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
<span key={`ellipsis-${idx}`} className="px-2 py-1 text-gray-500">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (page === 'first') {
|
||||
return (
|
||||
<button
|
||||
key="first"
|
||||
onClick={() => handlePageChange(1)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (page === 'last') {
|
||||
return (
|
||||
<button
|
||||
key="last"
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = page === currentPage;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageChange(page as number)}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white font-medium'
|
||||
: 'border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ 创建并导出注册表 ============
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
@@ -468,6 +954,24 @@ const { registry } = defineRegistry(catalog, {
|
||||
'login-panel': LoginPanel,
|
||||
'mcp-status': McpStatus,
|
||||
'suggestion-buttons': SuggestionButtons,
|
||||
|
||||
// Novel 相关组件
|
||||
'novel-item': NovelItem,
|
||||
count: Count,
|
||||
'refresh-button': RefreshButton,
|
||||
|
||||
// 筛选器相关组件
|
||||
filters: Filters,
|
||||
header: Header,
|
||||
'category-filter': CategoryFilter,
|
||||
'search-input': SearchInput,
|
||||
|
||||
// 缺失组件
|
||||
'search-bar': SearchBar,
|
||||
'filter-section': FilterSection,
|
||||
'novel-grid': NovelGrid,
|
||||
'pagination': Pagination,
|
||||
'search-section': SearchSection,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user