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:
Claude AI
2026-03-25 09:32:19 +00:00
parent dd8518e587
commit b08fa9764b
2 changed files with 685 additions and 23 deletions

View File

@@ -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: '搜索区域容器组件,用于包装搜索输入和刷新按钮',
},
},
});

View File

@@ -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,
},
});