From 40726e232de0d8b915d837fc709b30d1ded6cb4d Mon Sep 17 00:00:00 2001 From: HappySummer Date: Thu, 23 Apr 2026 20:26:51 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(DiscoveryView):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E8=87=AA=E5=8A=A8=E5=8A=A0=E8=BD=BD=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=B9=B6=E4=BC=98=E5=8C=96=E6=BB=9A=E5=8A=A8=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(RepositoryEditModal): 修复自定义描述为空时的处理逻辑 --- src/components/DiscoveryView.tsx | 48 ++++++-------------------- src/components/RepositoryEditModal.tsx | 18 +++++----- 2 files changed, 20 insertions(+), 46 deletions(-) diff --git a/src/components/DiscoveryView.tsx b/src/components/DiscoveryView.tsx index f4c4261..e5d0c8b 100644 --- a/src/components/DiscoveryView.tsx +++ b/src/components/DiscoveryView.tsx @@ -645,8 +645,6 @@ export const DiscoveryView: React.FC = React.memo(() => { const currentLastRefresh = discoveryLastRefresh?.[selectedDiscoveryChannel] ?? null; const currentIsLoading = discoveryIsLoading?.[selectedDiscoveryChannel] ?? false; - const currentHasMore = discoveryHasMore?.[selectedDiscoveryChannel] ?? false; - const currentNextPage = discoveryNextPage?.[selectedDiscoveryChannel] ?? 1; const currentChannel = safeDiscoveryChannels.find(ch => ch.id === selectedDiscoveryChannel); const currentChannelIcon = currentChannel?.icon || 'trending'; const currentChannelStyle = discoveryChannelStyleMap[currentChannelIcon] || discoveryChannelStyleMap.trending; @@ -940,12 +938,16 @@ export const DiscoveryView: React.FC = React.memo(() => { const handlePageChange = useCallback((page: number) => { setCurrentPage(page); - // 如果目标页的数据还没有加载,先加载数据 - const requiredItems = page * ITEMS_PER_PAGE; - if (allRepos.length < requiredItems && currentHasMore && !currentIsLoading) { - refreshChannel(selectedDiscoveryChannel, currentNextPage, true); + // 分页切换时滚动到顶部 + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + top: 0, + behavior: 'smooth' + }); } - }, [allRepos.length, currentHasMore, currentIsLoading, currentNextPage, refreshChannel, selectedDiscoveryChannel]); + + // 分页逻辑:仅设置当前页码,不再自动加载更多数据 + }, [setCurrentPage]); const refreshAll = useCallback(async () => { const enabledChannels = safeDiscoveryChannels.filter(ch => ch.enabled); @@ -1294,12 +1296,7 @@ export const DiscoveryView: React.FC = React.memo(() => { )} - {currentHasMore && ( -
-
- {t('还有更多', 'More available')} -
- )} +
)} @@ -1311,30 +1308,7 @@ export const DiscoveryView: React.FC = React.memo(() => { language={language} /> - {/* Load More Button */} - {currentHasMore && ( -
- -
- )} + {/* 滚动到底部按钮 */} diff --git a/src/components/RepositoryEditModal.tsx b/src/components/RepositoryEditModal.tsx index 0ae9a4b..33e1596 100644 --- a/src/components/RepositoryEditModal.tsx +++ b/src/components/RepositoryEditModal.tsx @@ -119,7 +119,7 @@ export const RepositoryEditModal: React.FC = ({ // 优先级: custom_description(非undefined) > ai_summary > description // custom_description === '' 表示用户明确清空,来源为 'none' let descSource: DataSource; - if (repo.custom_description !== undefined) { + if (repo.custom_description !== undefined && repo.custom_description !== null) { if (repo.custom_description.trim() !== '') { descSource = 'custom'; } else { @@ -176,7 +176,7 @@ export const RepositoryEditModal: React.FC = ({ // custom_description === '' 表示用户明确清空,表单中显示为空 // custom_description === undefined 表示无自定义,回退到AI/原始 let effectiveDescription = ''; - if (repo.custom_description !== undefined) { + if (repo.custom_description !== undefined && repo.custom_description !== null) { effectiveDescription = repo.custom_description; } else if (repo.ai_summary && repo.ai_summary.trim() !== '') { effectiveDescription = repo.ai_summary; @@ -277,7 +277,7 @@ export const RepositoryEditModal: React.FC = ({ case 'keep-custom': default: { // 保持自定义:检查内容是否与原始来源一致 - const descTrimmed = formData.description.trim(); + const descTrimmed = (formData.description || '').trim(); // 如果内容为空,视为清除 if (descTrimmed === '') { @@ -457,9 +457,9 @@ export const RepositoryEditModal: React.FC = ({ if (!repository) return { description: false, tags: false, category: false }; const isDescCustom = editIntent.description === 'keep-custom' && - formData.description.trim() !== '' && - formData.description.trim() !== (repository.ai_summary || '').trim() && - formData.description.trim() !== (repository.description || '').trim(); + (formData.description || '').trim() !== '' && + (formData.description || '').trim() !== (repository.ai_summary || '').trim() && + (formData.description || '').trim() !== (repository.description || '').trim(); const aiTags = repository.ai_tags || []; const topics = repository.topics || []; @@ -644,12 +644,12 @@ export const RepositoryEditModal: React.FC = ({ {/* Source Indicator */}
{t('当前来源:', 'Source:')} - {editIntent.description === 'keep-custom' && formData.description.trim() !== '' ? ( + {editIntent.description === 'keep-custom' && (formData.description || '').trim() !== '' ? ( {t('自定义', 'Custom')} - ) : editIntent.description === 'keep-custom' && formData.description.trim() === '' ? ( + ) : editIntent.description === 'keep-custom' && (formData.description || '').trim() === '' ? ( {t('将回退', 'Will fallback')} @@ -720,7 +720,7 @@ export const RepositoryEditModal: React.FC = ({

- ) : editIntent.description === 'keep-custom' && formData.description.trim() === '' ? ( + ) : editIntent.description === 'keep-custom' && (formData.description || '').trim() === '' ? (

From 1465a902d1dc6b4ab15084a9af977bb64a3dde28 Mon Sep 17 00:00:00 2001 From: HappySummer Date: Thu, 23 Apr 2026 22:28:50 +0800 Subject: [PATCH 2/7] =?UTF-8?q?refactor(DiscoveryView):=20=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E5=88=86=E9=A1=B5=E7=BB=84=E4=BB=B6=E4=B8=BA=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=9B=B4=E5=A4=9A=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除传统的分页组件,改为实现无限滚动加载模式 新增加载更多按钮组件,优化大数据量下的用户体验 简化页码管理逻辑,减少不必要的状态和计算 --- src/components/DiscoveryView.tsx | 415 +++++++++---------------------- 1 file changed, 116 insertions(+), 299 deletions(-) diff --git a/src/components/DiscoveryView.tsx b/src/components/DiscoveryView.tsx index e5d0c8b..3114d48 100644 --- a/src/components/DiscoveryView.tsx +++ b/src/components/DiscoveryView.tsx @@ -10,8 +10,6 @@ import { Crown, Filter, ChevronDown, - ChevronLeft, - ChevronRight, Monitor, Apple, Terminal, @@ -284,264 +282,104 @@ const PlatformFilter: React.FC = ({ platform, onPlatformCha ); }; -// Pagination Component -interface PaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; +interface LoadMoreButtonProps { + onLoadMore: () => void; + isLoading: boolean; + hasMore: boolean; + currentCount: number; + totalCount: number; language: 'zh' | 'en'; } -const Pagination: React.FC = ({ currentPage, totalPages, onPageChange, language }) => { +const LoadMoreButton: React.FC = ({ + onLoadMore, + isLoading, + hasMore, + currentCount, + totalCount, + language +}) => { const t = (zh: string, en: string) => language === 'zh' ? zh : en; - const [inputPage, setInputPage] = useState(currentPage.toString()); - const [isEditing, setIsEditing] = useState(false); - const inputRef = useRef(null); - - // 同步外部页码变化到输入框 - useEffect(() => { - if (!isEditing) { - setInputPage(currentPage.toString()); - } - }, [currentPage, isEditing]); - - // 聚焦输入框 - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); - - const getPageNumbers = () => { - const pages: (number | string)[] = []; - const delta = 2; - - for (let i = 1; i <= totalPages; i++) { - if ( - i === 1 || - i === totalPages || - (i >= currentPage - delta && i <= currentPage + delta) - ) { - pages.push(i); - } else if (pages[pages.length - 1] !== '...') { - pages.push('...'); - } - } - - return pages; - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - // 只允许数字 - if (value === '' || /^\d*$/.test(value)) { - setInputPage(value); - } - }; - - const handleInputSubmit = () => { - const pageNum = parseInt(inputPage, 10); - if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) { - onPageChange(pageNum); - } else { - // 无效输入,重置为当前页 - setInputPage(currentPage.toString()); - } - setIsEditing(false); - }; - - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleInputSubmit(); - } else if (e.key === 'Escape') { - setInputPage(currentPage.toString()); - setIsEditing(false); - } - }; - - const handleInputBlur = () => { - handleInputSubmit(); - }; - - if (totalPages <= 1) return null; - - return ( -

- -
- {getPageNumbers().map((page, index) => ( - - {page === '...' ? ( - ... - ) : ( - - )} - - ))} + if (!hasMore) { + return ( +
+
+
+ {t('已加载全部', 'All loaded')} +
+
+ + {t(`共 ${totalCount} 个项目`, `Total ${totalCount} items`)} +
+ ); + } + return ( +
- - {/* 可编辑页码跳转 */} -
- {t('跳转到', 'Go to')} - {isEditing ? ( - +
+ + {isLoading ? ( + <> + + {t('加载中...', 'Loading...')} + ) : ( - + <> + + {t('加载更多', 'Load More')} + )} - / {totalPages} + + +
+ + {t( + `已加载 ${currentCount} / ${totalCount} 个项目`, + `Loaded ${currentCount} / ${totalCount} items` + )} +
); }; -interface CompactPaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - language: 'zh' | 'en'; +interface DataStatsProps { + currentCount: number; totalCount: number; + language: 'zh' | 'en'; } -const CompactPagination: React.FC = ({ - currentPage, - totalPages, - onPageChange, - language, - totalCount -}) => { +const DataStats: React.FC = ({ currentCount, totalCount, language }) => { const t = (zh: string, en: string) => language === 'zh' ? zh : en; - const [inputPage, setInputPage] = useState(currentPage.toString()); - const [isEditing, setIsEditing] = useState(false); - const inputRef = useRef(null); - - useEffect(() => { - if (!isEditing) { - setInputPage(currentPage.toString()); - } - }, [currentPage, isEditing]); - - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); - - const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - if (value === '' || /^\d*$/.test(value)) { - setInputPage(value); - } - }; - - const handleInputSubmit = () => { - const pageNum = parseInt(inputPage, 10); - if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) { - onPageChange(pageNum); - } else { - setInputPage(currentPage.toString()); - } - setIsEditing(false); - }; - - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleInputSubmit(); - } else if (e.key === 'Escape') { - setInputPage(currentPage.toString()); - setIsEditing(false); - } - }; - - if (totalPages <= 1) return null; - + return ( -
- - - - {t('共', 'Total')} {totalCount} - - -
- {isEditing ? ( - - ) : ( - +
+
+ + {t('共', 'Total')} {currentCount} {t('个项目', 'items')} + {totalCount > 0 && currentCount < totalCount && ( + + {' '}{t('(总计', '(total')} {totalCount} {t('个)', 'items)')} + )} - / {totalPages} -
- - +
); }; @@ -591,10 +429,6 @@ export const DiscoveryView: React.FC = React.memo(() => { const [, setAnalysisState] = useState<{ paused: boolean; aborted: boolean }>({ paused: false, aborted: false }); const [searchInput, setSearchInput] = useState(discoverySearchQuery); - // 当前页码状态(从1开始) - const [currentPage, setCurrentPage] = useState(1); - - // 滚动容器引用 const scrollContainerRef = useRef(null); // 工具栏显示状态 const [isToolbarVisible, setIsToolbarVisible] = useState(true); @@ -615,8 +449,6 @@ export const DiscoveryView: React.FC = React.memo(() => { [discoveryChannels] ); - const ITEMS_PER_PAGE = 20; - // 获取当前频道的所有仓库 const allRepos = useMemo( () => (discoveryRepos && discoveryRepos[selectedDiscoveryChannel]) || [], @@ -626,23 +458,6 @@ export const DiscoveryView: React.FC = React.memo(() => { // 从 store 获取当前频道的总数量 const currentTotalCount = discoveryTotalCount?.[selectedDiscoveryChannel] ?? 0; - const totalPages = useMemo(() => { - if (currentTotalCount > 0) { - return Math.ceil(currentTotalCount / ITEMS_PER_PAGE); - } - if (allRepos.length > 0) { - return Math.ceil(allRepos.length / ITEMS_PER_PAGE); - } - return 0; - }, [currentTotalCount, allRepos.length]); - - // 获取当前页显示的仓库 - const currentPageRepos = useMemo(() => { - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - return allRepos.slice(startIndex, endIndex); - }, [allRepos, currentPage]); - const currentLastRefresh = discoveryLastRefresh?.[selectedDiscoveryChannel] ?? null; const currentIsLoading = discoveryIsLoading?.[selectedDiscoveryChannel] ?? false; const currentChannel = safeDiscoveryChannels.find(ch => ch.id === selectedDiscoveryChannel); @@ -735,9 +550,8 @@ export const DiscoveryView: React.FC = React.memo(() => { } }, [githubToken, t, setDiscoveryLoading, setDiscoveryRepos, setDiscoveryLastRefresh, discoveryPlatform, discoveryLanguage, discoverySortBy, discoverySortOrder, discoverySearchQuery, discoverySelectedTopic, setDiscoveryHasMore, setDiscoveryNextPage, setDiscoveryTotalCount, appendDiscoveryRepos]); - // 切换频道时重置页码、恢复滚动位置,并自动加载空数据 + // 切换频道时恢复滚动位置,并自动加载空数据 useEffect(() => { - setCurrentPage(1); // 恢复当前频道的滚动位置(从 ref 读取最新值,避免订阅整个 map) if (scrollContainerRef.current) { const savedPosition = discoveryScrollPositionsRef.current[selectedDiscoveryChannel] || 0; @@ -821,11 +635,10 @@ export const DiscoveryView: React.FC = React.memo(() => { return; } - // 获取当前页的20个项目 - const pageRepos = currentPageRepos; + const pageRepos = allRepos; if (pageRepos.length === 0) { - alert(t('当前页面没有项目。', 'No projects on current page.')); + alert(t('当前没有项目。', 'No projects available.')); return; } @@ -915,7 +728,7 @@ export const DiscoveryView: React.FC = React.memo(() => { setAnalysisOptimizer(null); setAnalysisProgress({ current: 0, total: 0 }); } - }, [githubToken, aiConfigs, activeAIConfig, language, currentPageRepos, t, updateDiscoveryRepo, setAnalysisProgress]); + }, [githubToken, aiConfigs, activeAIConfig, language, allRepos, t, updateDiscoveryRepo, setAnalysisProgress]); @@ -935,19 +748,28 @@ export const DiscoveryView: React.FC = React.memo(() => { } }, [selectedDiscoveryChannel, searchInput, setDiscoverySearchQuery, refreshChannel]); - const handlePageChange = useCallback((page: number) => { - setCurrentPage(page); + const handleLoadMore = useCallback(async () => { + if (!discoveryHasMore[selectedDiscoveryChannel]) { + return; + } - // 分页切换时滚动到顶部 - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - top: 0, - behavior: 'smooth' - }); + if (currentIsLoading) { + return; + } + + const nextPage = discoveryNextPage[selectedDiscoveryChannel]; + if (!nextPage) { + return; } - // 分页逻辑:仅设置当前页码,不再自动加载更多数据 - }, [setCurrentPage]); + await refreshChannel(selectedDiscoveryChannel, nextPage, true); + }, [ + discoveryHasMore, + discoveryNextPage, + selectedDiscoveryChannel, + currentIsLoading, + refreshChannel + ]); const refreshAll = useCallback(async () => { const enabledChannels = safeDiscoveryChannels.filter(ch => ch.enabled); @@ -972,14 +794,12 @@ export const DiscoveryView: React.FC = React.memo(() => { channels={mobileChannels} selectedChannel={selectedDiscoveryChannel} onChannelSelect={(channel) => { - // 保存当前频道的滚动位置到 ref 和 state if (scrollContainerRef.current) { const scrollTop = scrollContainerRef.current.scrollTop; discoveryScrollPositionsRef.current[selectedDiscoveryChannel] = scrollTop; setDiscoveryScrollPosition(selectedDiscoveryChannel, scrollTop); } setSelectedDiscoveryChannel(channel); - setCurrentPage(1); }} language={language} /> @@ -990,14 +810,12 @@ export const DiscoveryView: React.FC = React.memo(() => { channels={safeDiscoveryChannels} selectedChannel={selectedDiscoveryChannel} onChannelSelect={(channel) => { - // 保存当前频道的滚动位置到 ref 和 state if (scrollContainerRef.current) { const scrollTop = scrollContainerRef.current.scrollTop; discoveryScrollPositionsRef.current[selectedDiscoveryChannel] = scrollTop; setDiscoveryScrollPosition(selectedDiscoveryChannel, scrollTop); } setSelectedDiscoveryChannel(channel); - setCurrentPage(1); }} onRefreshAll={refreshAll} isLoading={discoveryIsLoading} @@ -1116,18 +934,16 @@ export const DiscoveryView: React.FC = React.memo(() => { onClick={handleAnalyzePage} disabled={isAnalyzing || currentIsLoading} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300 hover:bg-purple-200 dark:hover:bg-purple-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - title={t('AI分析当前页', 'Analyze current page with AI')} + title={t('AI分析', 'Analyze with AI')} > {t('AI分析', 'AI Analyze')} )} -
@@ -1277,7 +1093,7 @@ export const DiscoveryView: React.FC = React.memo(() => { )}
- {currentPageRepos.map(repo => ( + {allRepos.map(repo => ( ))}
@@ -1290,23 +1106,24 @@ export const DiscoveryView: React.FC = React.memo(() => {
- {t('共', 'Total')} {currentTotalCount || allRepos.length} {t('个项目', 'items')} - {totalPages > 1 && ( - <> · {t('第', 'Page')} {currentPage}/{totalPages} {t('页', 'pages')} - )} + {t('共', 'Total')} {allRepos.length} {t('个项目', 'items')}
)} - {/* Pagination */} - + {/* Load More Button */} + {allRepos.length > 0 && ( + + )}
From 3239ddd8f82002c12efe1afc99e082eecc9e709a Mon Sep 17 00:00:00 2001 From: HappySummer Date: Fri, 24 Apr 2026 01:24:48 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(discovery):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E6=9B=B4=E5=A4=9A=E5=8A=9F=E8=83=BD=E5=8F=8A?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加 discoveryIsLoadingMore 和 discoveryLoadMoreError 状态用于管理加载更多操作 实现加载更多时的滚动定位和错误处理 优化频道切换时的状态重置逻辑 --- src/components/DiscoveryView.tsx | 93 +++++++++++++++++++++++----- src/store/useAppStore.ts | 101 +++++++++++++++++++++---------- src/types/index.ts | 2 + 3 files changed, 150 insertions(+), 46 deletions(-) diff --git a/src/components/DiscoveryView.tsx b/src/components/DiscoveryView.tsx index cb4dab6..8ae44e0 100644 --- a/src/components/DiscoveryView.tsx +++ b/src/components/DiscoveryView.tsx @@ -34,7 +34,8 @@ import type { ProgrammingLanguage, SortBy, SortOrder, - TopicCategory + TopicCategory, + TrendingTimeRange } from '../types'; const discoveryChannelIconMap: Record = { @@ -393,9 +394,13 @@ export const DiscoveryView: React.FC = React.memo(() => { discoveryRepos, discoveryLastRefresh, discoveryIsLoading, + discoveryIsLoadingMore, + discoveryLoadMoreError, selectedDiscoveryChannel, setSelectedDiscoveryChannel, setDiscoveryLoading, + setDiscoveryLoadingMore, + setDiscoveryLoadMoreError, setDiscoveryRepos, setDiscoveryLastRefresh, updateDiscoveryRepo, @@ -463,6 +468,8 @@ export const DiscoveryView: React.FC = React.memo(() => { const currentLastRefresh = discoveryLastRefresh?.[selectedDiscoveryChannel] ?? null; const currentIsLoading = discoveryIsLoading?.[selectedDiscoveryChannel] ?? false; + const currentIsLoadingMore = discoveryIsLoadingMore?.[selectedDiscoveryChannel] ?? false; + const currentLoadMoreError = discoveryLoadMoreError?.[selectedDiscoveryChannel] ?? null; const currentChannel = safeDiscoveryChannels.find(ch => ch.id === selectedDiscoveryChannel); const currentChannelIcon = currentChannel?.icon || 'trending'; const currentChannelStyle = discoveryChannelStyleMap[currentChannelIcon] || discoveryChannelStyleMap.trending; @@ -476,7 +483,12 @@ export const DiscoveryView: React.FC = React.memo(() => { return; } - setDiscoveryLoading(channelId, true); + if (append) { + setDiscoveryLoadingMore(channelId, true); + setDiscoveryLoadMoreError(channelId, null); + } else { + setDiscoveryLoading(channelId, true); + } try { const githubApi = new GitHubApiService(githubToken); let result; @@ -516,7 +528,8 @@ export const DiscoveryView: React.FC = React.memo(() => { result = { repos: [], hasMore: false, nextPageIndex: page + 1, totalCount: 0 }; } - // 合并AI分析结果(如果仓库之前被分析过) + const prevCount = useAppStore.getState().discoveryRepos[channelId]?.length ?? 0; + const currentAllRepos = useAppStore.getState().discoveryRepos[channelId] || []; const mergedRepos = result.repos.map((newRepo: DiscoveryRepo) => { const existingRepo = currentAllRepos.find((r: DiscoveryRepo) => r.id === newRepo.id); @@ -540,18 +553,36 @@ export const DiscoveryView: React.FC = React.memo(() => { } setDiscoveryHasMore(channelId, result.hasMore); setDiscoveryNextPage(channelId, result.nextPageIndex); - // 保存总数量用于计算总页数 if (result.totalCount !== undefined) { setDiscoveryTotalCount(channelId, result.totalCount); } setDiscoveryLastRefresh(channelId, new Date().toISOString()); + + if (append && scrollContainerRef.current) { + requestAnimationFrame(() => { + if (!scrollContainerRef.current) return; + const repoCards = scrollContainerRef.current.querySelectorAll('[data-repo-index]'); + const targetCard = repoCards[prevCount] as HTMLElement | undefined; + if (targetCard) { + targetCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + } } catch (error) { console.error(`Failed to refresh channel ${channelId}:`, error); - alert(t('获取数据失败,请检查网络连接或GitHub Token。', 'Failed to fetch data. Please check your network connection or GitHub Token.')); + if (append) { + setDiscoveryLoadMoreError(channelId, t('加载更多失败,请重试', 'Failed to load more, please retry')); + } else { + alert(t('获取数据失败,请检查网络连接或GitHub Token。', 'Failed to fetch data. Please check your network connection or GitHub Token.')); + } } finally { - setDiscoveryLoading(channelId, false); + if (append) { + setDiscoveryLoadingMore(channelId, false); + } else { + setDiscoveryLoading(channelId, false); + } } - }, [githubToken, t, setDiscoveryLoading, setDiscoveryRepos, setDiscoveryLastRefresh, discoveryPlatform, discoveryLanguage, discoverySortBy, discoverySortOrder, discoverySearchQuery, discoverySelectedTopic, setDiscoveryHasMore, setDiscoveryNextPage, setDiscoveryTotalCount, appendDiscoveryRepos, trendingTimeRange]); + }, [githubToken, t, setDiscoveryLoading, setDiscoveryLoadingMore, setDiscoveryLoadMoreError, setDiscoveryRepos, setDiscoveryLastRefresh, discoveryPlatform, discoveryLanguage, discoverySortBy, discoverySortOrder, discoverySearchQuery, discoverySelectedTopic, setDiscoveryHasMore, setDiscoveryNextPage, setDiscoveryTotalCount, appendDiscoveryRepos, trendingTimeRange]); // 切换频道时恢复滚动位置,并自动加载空数据 useEffect(() => { @@ -1116,14 +1147,46 @@ export const DiscoveryView: React.FC = React.memo(() => {
)} -
- {allRepos.map(repo => ( - - ))} -
+ {allRepos.length > 0 && ( +
+ {allRepos.map((repo, index) => ( +
+ +
+ ))} +
+ )} + + {currentIsLoadingMore && ( +
+ + {t('正在加载更多...', 'Loading more...')} +
+ )} + + {currentLoadMoreError && ( +
+
+ + {currentLoadMoreError} +
+ +
+ )} {/* Page Info */} - {allRepos.length > 0 && ( + {!currentIsLoading && allRepos.length > 0 && (
@@ -1138,10 +1201,10 @@ export const DiscoveryView: React.FC = React.memo(() => { )} {/* Load More Button */} - {allRepos.length > 0 && ( + {!currentIsLoading && !currentIsLoadingMore && allRepos.length > 0 && ( void; setDiscoveryLoading: (channel: DiscoveryChannelId, loading: boolean) => void; + setDiscoveryLoadingMore: (channel: DiscoveryChannelId, loading: boolean) => void; + setDiscoveryLoadMoreError: (channel: DiscoveryChannelId, error: string | null) => void; setDiscoveryRepos: (channel: DiscoveryChannelId, repos: DiscoveryRepo[], append?: boolean) => void; setDiscoveryLastRefresh: (channel: DiscoveryChannelId, timestamp: string) => void; updateDiscoveryRepo: (repo: DiscoveryRepo) => void; @@ -229,6 +231,7 @@ type PersistedAppState = Partial< | 'releaseViewMode' | 'releaseSelectedFilters' | 'releaseSearchQuery' + | 'discoveryChannels' | 'discoveryRepos' | 'discoveryLastRefresh' | 'discoveryTotalCount' @@ -331,13 +334,13 @@ const normalizePersistedState = ( discoveryRepos: (() => { const persisted = (safePersisted as Record).discoveryRepos; if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) { + const persistedRepos = persisted as Record; return { - 'trending': [], - 'hot-release': [], - 'most-popular': [], - 'topic': [], - 'search': [], - ...(persisted as Record), + 'trending': persistedRepos['trending'] || [], + 'hot-release': persistedRepos['hot-release'] || [], + 'most-popular': persistedRepos['most-popular'] || [], + 'topic': persistedRepos['topic'] || [], + 'search': persistedRepos['search'] || [], }; } return { 'trending': [], 'hot-release': [], 'most-popular': [], 'topic': [], 'search': [] } as Record; @@ -345,13 +348,13 @@ const normalizePersistedState = ( discoveryLastRefresh: (() => { const persisted = (safePersisted as Record).discoveryLastRefresh; if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) { + const persistedRefresh = persisted as Record; return { - 'trending': null, - 'hot-release': null, - 'most-popular': null, - 'topic': null, - 'search': null, - ...(persisted as Record), + 'trending': persistedRefresh['trending'] || null, + 'hot-release': persistedRefresh['hot-release'] || null, + 'most-popular': persistedRefresh['most-popular'] || null, + 'topic': persistedRefresh['topic'] || null, + 'search': persistedRefresh['search'] || null, }; } return { 'trending': null, 'hot-release': null, 'most-popular': null, 'topic': null, 'search': null }; @@ -359,13 +362,13 @@ const normalizePersistedState = ( discoveryTotalCount: (() => { const persisted = (safePersisted as Record).discoveryTotalCount; if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) { + const persistedCount = persisted as Record; return { - 'trending': 0, - 'hot-release': 0, - 'most-popular': 0, - 'topic': 0, - 'search': 0, - ...(persisted as Record), + 'trending': persistedCount['trending'] || 0, + 'hot-release': persistedCount['hot-release'] || 0, + 'most-popular': persistedCount['most-popular'] || 0, + 'topic': persistedCount['topic'] || 0, + 'search': persistedCount['search'] || 0, }; } return { 'trending': 0, 'hot-release': 0, 'most-popular': 0, 'topic': 0, 'search': 0 }; @@ -375,17 +378,19 @@ const normalizePersistedState = ( : 'trending', // discoveryIsLoading 不持久化,始终重置为 false(防止旧数据格式异常) discoveryIsLoading: { 'trending': false, 'hot-release': false, 'most-popular': false, 'topic': false, 'search': false }, + discoveryIsLoadingMore: { 'trending': false, 'hot-release': false, 'most-popular': false, 'topic': false, 'search': false }, + discoveryLoadMoreError: { 'trending': null, 'hot-release': null, 'most-popular': null, 'topic': null, 'search': null }, // discoveryHasMore 从持久化恢复,确保对象格式 discoveryHasMore: (() => { const persisted = (safePersisted as Record).discoveryHasMore; if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) { + const persistedHasMore = persisted as Record; return { - 'trending': false, - 'hot-release': false, - 'most-popular': false, - 'topic': false, - 'search': false, - ...(persisted as Record), + 'trending': persistedHasMore['trending'] || false, + 'hot-release': persistedHasMore['hot-release'] || false, + 'most-popular': persistedHasMore['most-popular'] || false, + 'topic': persistedHasMore['topic'] || false, + 'search': persistedHasMore['search'] || false, }; } return { 'trending': false, 'hot-release': false, 'most-popular': false, 'topic': false, 'search': false }; @@ -394,13 +399,13 @@ const normalizePersistedState = ( discoveryNextPage: (() => { const persisted = (safePersisted as Record).discoveryNextPage; if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) { + const persistedPage = persisted as Record; return { - 'trending': 1, - 'hot-release': 1, - 'most-popular': 1, - 'topic': 1, - 'search': 1, - ...(persisted as Record), + 'trending': persistedPage['trending'] || 1, + 'hot-release': persistedPage['hot-release'] || 1, + 'most-popular': persistedPage['most-popular'] || 1, + 'topic': persistedPage['topic'] || 1, + 'search': persistedPage['search'] || 1, }; } return { 'trending': 1, 'hot-release': 1, 'most-popular': 1, 'topic': 1, 'search': 1 }; @@ -650,6 +655,8 @@ export const useAppStore = create()( discoveryRepos: { 'trending': [], 'hot-release': [], 'most-popular': [], 'topic': [], 'search': [] }, discoveryLastRefresh: { 'trending': null, 'hot-release': null, 'most-popular': null, 'topic': null, 'search': null }, discoveryIsLoading: { 'trending': false, 'hot-release': false, 'most-popular': false, 'topic': false, 'search': false }, + discoveryIsLoadingMore: { 'trending': false, 'hot-release': false, 'most-popular': false, 'topic': false, 'search': false }, + discoveryLoadMoreError: { 'trending': null, 'hot-release': null, 'most-popular': null, 'topic': null, 'search': null }, selectedDiscoveryChannel: 'trending', discoveryPlatform: 'All', discoveryLanguage: 'All', @@ -1169,10 +1176,42 @@ export const useAppStore = create()( setReleaseIsRefreshing: (releaseIsRefreshing) => set({ releaseIsRefreshing }), // Discovery actions - setSelectedDiscoveryChannel: (selectedDiscoveryChannel) => set({ selectedDiscoveryChannel }), + setSelectedDiscoveryChannel: (selectedDiscoveryChannel) => set((state) => ({ + selectedDiscoveryChannel, + discoveryRepos: { + ...state.discoveryRepos, + [selectedDiscoveryChannel]: [] + }, + discoveryNextPage: { + ...state.discoveryNextPage, + [selectedDiscoveryChannel]: 1 + }, + discoveryHasMore: { + ...state.discoveryHasMore, + [selectedDiscoveryChannel]: false + }, + discoveryTotalCount: { + ...state.discoveryTotalCount, + [selectedDiscoveryChannel]: 0 + }, + discoveryIsLoadingMore: { + ...state.discoveryIsLoadingMore, + [selectedDiscoveryChannel]: false + }, + discoveryLoadMoreError: { + ...state.discoveryLoadMoreError, + [selectedDiscoveryChannel]: null + } + })), setDiscoveryLoading: (channel, loading) => set((state) => ({ discoveryIsLoading: { ...state.discoveryIsLoading, [channel]: loading }, })), + setDiscoveryLoadingMore: (channel, loading) => set((state) => ({ + discoveryIsLoadingMore: { ...state.discoveryIsLoadingMore, [channel]: loading }, + })), + setDiscoveryLoadMoreError: (channel, error) => set((state) => ({ + discoveryLoadMoreError: { ...state.discoveryLoadMoreError, [channel]: error }, + })), setDiscoveryRepos: (channel, repos, append = false) => set((state) => ({ discoveryRepos: { ...state.discoveryRepos, diff --git a/src/types/index.ts b/src/types/index.ts index e6d4b3d..a55faa0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -199,6 +199,8 @@ export interface AppState { discoveryRepos: Record; discoveryLastRefresh: Record; discoveryIsLoading: Record; + discoveryIsLoadingMore: Record; + discoveryLoadMoreError: Record; selectedDiscoveryChannel: DiscoveryChannelId; discoveryPlatform: DiscoveryPlatform; discoveryLanguage: ProgrammingLanguage; From 520ca42e9dfc44b4d4043597eb7b39a011c18fa6 Mon Sep 17 00:00:00 2001 From: HappySummer Date: Fri, 24 Apr 2026 03:37:48 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(ai):=20=E6=B7=BB=E5=8A=A0=E5=AF=B9=20o?= =?UTF-8?q?penai-compatible=20=E8=87=AA=E5=AE=9A=E4=B9=89=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E7=9A=84=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展 AI 服务类型以支持兼容 OpenAI API 的自定义端点,包括: 1. 在类型定义中添加 'openai-compatible' 选项 2. 修改请求处理逻辑以直接使用自定义端点的完整 URL 3. 在配置面板中增加相关说明和最终请求地址预览 --- server/src/routes/proxy.ts | 9 +++-- src/components/settings/AIConfigPanel.tsx | 27 ++++++++++--- src/services/aiService.ts | 49 +++-------------------- src/types/index.ts | 2 +- src/utils/apiUrlBuilder.ts | 45 +++++++++++++++++++++ 5 files changed, 79 insertions(+), 53 deletions(-) create mode 100644 src/utils/apiUrlBuilder.ts diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index d2b307d..cb62bcf 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -116,8 +116,11 @@ router.post('/api/proxy/ai', async (req, res) => { 'Accept': 'application/json', }; - if (apiType === 'openai' || apiType === 'openai-responses') { - targetUrl = buildApiUrl(baseUrl, apiType === 'openai-responses' ? 'v1/responses' : 'v1/chat/completions'); + if (apiType === 'openai' || apiType === 'openai-responses' || apiType === 'openai-compatible') { + // openai-compatible 类型直接使用 baseUrl 作为完整地址 + targetUrl = apiType === 'openai-compatible' + ? baseUrl.replace(/\/$/, '') + : buildApiUrl(baseUrl, apiType === 'openai-responses' ? 'v1/responses' : 'v1/chat/completions'); headers['Authorization'] = `Bearer ${apiKey}`; } else if (apiType === 'claude') { targetUrl = buildApiUrl(baseUrl, 'v1/messages'); @@ -138,7 +141,7 @@ router.post('/api/proxy/ai', async (req, res) => { reasoningEffort && typeof requestBody === 'object' && requestBody !== null - && (apiType === 'openai' || apiType === 'openai-responses') + && (apiType === 'openai' || apiType === 'openai-responses' || apiType === 'openai-compatible') && !('reasoning' in requestBody) ) ? { ...requestBody, reasoning: { effort: reasoningEffort } } diff --git a/src/components/settings/AIConfigPanel.tsx b/src/components/settings/AIConfigPanel.tsx index 6fdf1bf..39bc528 100644 --- a/src/components/settings/AIConfigPanel.tsx +++ b/src/components/settings/AIConfigPanel.tsx @@ -3,6 +3,7 @@ import { Bot, Plus, Edit3, Trash2, Save, X, TestTube, RefreshCw, MessageSquare, import { AIConfig, AIApiType, AIReasoningEffort } from '../../types'; import { useAppStore } from '../../store/useAppStore'; import { AIService } from '../../services/aiService'; +import { buildFinalApiUrl } from '../../utils/apiUrlBuilder'; interface AIConfigPanelProps { t: (zh: string, en: string) => string; @@ -357,6 +358,7 @@ Focus on practicality and accurate categorization to help users quickly understa +
@@ -374,15 +376,30 @@ Focus on practicality and accurate categorization to help users quickly understa ? 'https://api.openai.com/v1' : form.apiType === 'claude' ? 'https://api.anthropic.com/v1' - : 'https://generativelanguage.googleapis.com/v1beta' + : form.apiType === 'openai-compatible' + ? 'https://integrate.api.nvidia.com/v1/chat/completions' + : 'https://generativelanguage.googleapis.com/v1beta' } />

- {t( - '只填到版本号即可(如 .../v1 或 .../v1beta),不要包含 /chat/completions、/responses、/messages 或 :generateContent', - 'Only include the version prefix (e.g. .../v1 or .../v1beta). Do not include /chat/completions, /responses, /messages, or :generateContent.' - )} + {form.apiType === 'openai-compatible' + ? t( + '填写完整的API调用地址,包含完整路径', + 'Enter the full API endpoint URL including the complete path' + ) + : t( + '只填到版本号即可(如 .../v1 或 .../v1beta),不要包含 /chat/completions、/responses、/messages 或 :generateContent', + 'Only include the version prefix (e.g. .../v1 or .../v1beta). Do not include /chat/completions, /responses, /messages, or :generateContent.' + )}

+ {form.baseUrl && ( +

+ {t('最终请求地址: ', 'Final request URL: ')} + + {buildFinalApiUrl(form.baseUrl, form.apiType)} + +

+ )}
diff --git a/src/services/aiService.ts b/src/services/aiService.ts index ebcd231..834b320 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -1,5 +1,6 @@ import { Repository, AIConfig, AIApiType } from '../types'; import { backend } from './backendAdapter'; +import { buildApiUrl, buildFinalApiUrl } from '../utils/apiUrlBuilder'; interface OpenAIResponseContentPart { text?: string; @@ -46,46 +47,6 @@ export class AIService { return this.getApiType() === 'openai' && this.config.model.trim() === 'deepseek-reasoner'; } - private buildApiUrl(pathWithVersion: string): string { - const baseUrlWithSlash = this.config.baseUrl.endsWith('/') - ? this.config.baseUrl - : `${this.config.baseUrl}/`; - - const versionPrefix = pathWithVersion.split('/')[0] || ''; - - try { - const base = new URL(baseUrlWithSlash); - const basePath = base.pathname.replace(/\/$/, ''); - - // 检测 baseUrl 是否已经以任何版本号结尾(v1, v2, v3, v1beta, v1alpha 等) - // 这样可以兼容火山引擎(/v3)、OpenAI(/v1)、Gemini(/v1beta)等不同版本号 - const anyVersionPattern = /\/v\d+(?:beta|alpha)?$/; - const hasVersionInBase = anyVersionPattern.test(basePath); - - if (hasVersionInBase) { - // baseUrl 已包含版本号,只补全端点路径(去掉版本号部分) - const endpointPath = pathWithVersion.includes('/') - ? pathWithVersion.split('/').slice(1).join('/') - : pathWithVersion; - return new URL(endpointPath, baseUrlWithSlash).toString(); - } - - // 兼容用户把 baseUrl 写成 .../v1 或 .../v1beta 的情况,避免拼成 /v1/v1/... - if (versionPrefix) { - const versionRe = new RegExp(`/${versionPrefix}$`); - if (versionRe.test(basePath) && pathWithVersion.startsWith(`${versionPrefix}/`)) { - const rest = pathWithVersion.slice(versionPrefix.length + 1); - return new URL(rest, baseUrlWithSlash).toString(); - } - } - - return new URL(pathWithVersion, baseUrlWithSlash).toString(); - } catch { - // baseUrl 非绝对 URL 时这里会抛错;上层会在 testConnection/调用处处理失败 - return `${baseUrlWithSlash}${pathWithVersion}`; - } - } - private async requestText(options: { system: string; user: string; @@ -96,7 +57,7 @@ export class AIService { const apiType = this.getApiType(); const reasoning = this.getOpenAIReasoningPayload(); - if (apiType === 'openai' || apiType === 'openai-responses') { + if (apiType === 'openai' || apiType === 'openai-responses' || apiType === 'openai-compatible') { const messages = [ ...(options.system.trim() ? [{ role: 'system', content: options.system }] @@ -125,7 +86,7 @@ export class AIService { if (backend.isAvailable && this.config.id) { data = await backend.proxyAIRequest(this.config.id, requestBody) as Record; } else { - const url = this.buildApiUrl(apiType === 'openai-responses' ? 'v1/responses' : 'v1/chat/completions'); + const url = buildFinalApiUrl(this.config.baseUrl, apiType); const response = await fetch(url, { method: 'POST', headers: { @@ -182,7 +143,7 @@ export class AIService { if (backend.isAvailable && this.config.id) { data = await backend.proxyAIRequest(this.config.id, requestBody); } else { - const url = this.buildApiUrl('v1/messages'); + const url = buildApiUrl(this.config.baseUrl, 'v1/messages'); const response = await fetch(url, { method: 'POST', headers: { @@ -238,7 +199,7 @@ ${options.user}` : options.user; data = await backend.proxyAIRequest(this.config.id, requestBody); } else { const path = `v1beta/models/${encodeURIComponent(model)}:generateContent`; - const urlObj = new URL(this.buildApiUrl(path)); + const urlObj = new URL(buildApiUrl(this.config.baseUrl, path)); urlObj.searchParams.set('key', this.config.apiKey); const response = await fetch(urlObj.toString(), { method: 'POST', diff --git a/src/types/index.ts b/src/types/index.ts index a55faa0..fe93478 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -67,7 +67,7 @@ export interface GitHubUser { email: string | null; } -export type AIApiType = 'openai' | 'openai-responses' | 'claude' | 'gemini'; +export type AIApiType = 'openai' | 'openai-responses' | 'claude' | 'gemini' | 'openai-compatible'; export type AIReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'xhigh'; export type SecretStatus = 'ok' | 'empty' | 'decrypt_failed'; diff --git a/src/utils/apiUrlBuilder.ts b/src/utils/apiUrlBuilder.ts new file mode 100644 index 0000000..d609223 --- /dev/null +++ b/src/utils/apiUrlBuilder.ts @@ -0,0 +1,45 @@ +import { AIApiType } from '../types'; + +export function buildApiUrl(baseUrl: string, pathWithVersion: string): string { + const baseUrlWithSlash = baseUrl.endsWith('/') + ? baseUrl + : `${baseUrl}/`; + + const versionPrefix = pathWithVersion.split('/')[0] || ''; + + try { + const base = new URL(baseUrlWithSlash); + const basePath = base.pathname.replace(/\/$/, ''); + + const anyVersionPattern = /\/v\d+(?:beta|alpha)?$/; + const hasVersionInBase = anyVersionPattern.test(basePath); + + if (hasVersionInBase) { + const endpointPath = pathWithVersion.includes('/') + ? pathWithVersion.split('/').slice(1).join('/') + : pathWithVersion; + return new URL(endpointPath, baseUrlWithSlash).toString(); + } + + if (versionPrefix) { + const versionRe = new RegExp(`/${versionPrefix}$`); + if (versionRe.test(basePath) && pathWithVersion.startsWith(`${versionPrefix}/`)) { + const rest = pathWithVersion.slice(versionPrefix.length + 1); + return new URL(rest, baseUrlWithSlash).toString(); + } + } + + return new URL(pathWithVersion, baseUrlWithSlash).toString(); + } catch { + return `${baseUrlWithSlash}${pathWithVersion}`; + } +} + +export function buildFinalApiUrl(baseUrl: string, apiType: AIApiType): string { + if (apiType === 'openai-compatible') { + return baseUrl.replace(/\/$/, ''); + } + + const pathWithVersion = apiType === 'openai-responses' ? 'v1/responses' : 'v1/chat/completions'; + return buildApiUrl(baseUrl, pathWithVersion); +} From 2dc052874f4e65e4f8f520ee47fee976bc44d4b5 Mon Sep 17 00:00:00 2001 From: HappySummer Date: Fri, 24 Apr 2026 03:50:22 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(DiscoveryView):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=8F=91=E7=8E=B0=E6=97=B6=E7=9A=84=E7=A9=BA?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当选择搜索频道时,显示不同的空状态UI和提示信息,包括搜索图标和搜索引导文本,而非默认的刷新按钮和数据提示 --- src/components/DiscoveryView.tsx | 74 +++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/src/components/DiscoveryView.tsx b/src/components/DiscoveryView.tsx index 8ae44e0..833b526 100644 --- a/src/components/DiscoveryView.tsx +++ b/src/components/DiscoveryView.tsx @@ -1117,33 +1117,57 @@ export const DiscoveryView: React.FC = React.memo(() => { {!currentIsLoading && allRepos.length === 0 && (
- {isDesktopSafeMode ? ( -
- {currentChannelIconNode} -
+ {selectedDiscoveryChannel === 'search' ? ( + <> + {isDesktopSafeMode ? ( +
+ {currentChannelIconNode} +
+ ) : ( +
+ {currentChannelStyle.largeIcon} +
+ )} +
+

+ {t('搜索发现', 'Search & Discover')} +

+

+ {t('输入关键字搜索 GitHub 仓库', 'Enter keywords to search GitHub repositories')} +

+
+ ) : ( -
- {currentChannelStyle.largeIcon} -
+ <> + {isDesktopSafeMode ? ( +
+ {currentChannelIconNode} +
+ ) : ( +
+ {currentChannelStyle.largeIcon} +
+ )} +
+

+ {t('暂无数据', 'No data yet')} +

+

+ {t('点击刷新按钮获取最新排行数据', 'Click refresh to fetch latest rankings')} +

+
+ + )} -
-

- {t('暂无数据', 'No data yet')} -

-

- {t('点击刷新按钮获取最新排行数据', 'Click refresh to fetch latest rankings')} -

-
-
)} From 5a7f3a919ad4f3cd8b57d043b5500ea12a281aa2 Mon Sep 17 00:00:00 2001 From: HappySummer Date: Fri, 24 Apr 2026 04:00:04 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat(=E5=8F=91=E7=8E=B0=E8=A7=86=E5=9B=BE):?= =?UTF-8?q?=20=E6=B7=BB=E5=8A=A0AI=E5=88=86=E6=9E=90=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E5=AD=98=E5=82=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现使用IndexedDB存储和加载仓库AI分析结果,包括保存、加载、批量加载和删除功能。在DiscoveryView组件中集成该存储服务,确保分析结果在页面刷新后仍可保留。 --- src/components/DiscoveryView.tsx | 24 ++++ src/services/discoveryAnalysisStorage.ts | 157 +++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/services/discoveryAnalysisStorage.ts diff --git a/src/components/DiscoveryView.tsx b/src/components/DiscoveryView.tsx index 833b526..8dc8934 100644 --- a/src/components/DiscoveryView.tsx +++ b/src/components/DiscoveryView.tsx @@ -22,6 +22,7 @@ import { useAppStore } from '../store/useAppStore'; import { GitHubApiService } from '../services/githubApi'; import { AIService } from '../services/aiService'; import { AIAnalysisOptimizer } from '../services/aiAnalysisOptimizer'; +import { discoveryAnalysisStorage } from '../services/discoveryAnalysisStorage'; import { DiscoverySidebar } from './DiscoverySidebar'; import { SubscriptionRepoCard } from './SubscriptionRepoCard'; import { SortAlgorithmTooltip } from './SortAlgorithmTooltip'; @@ -531,6 +532,7 @@ export const DiscoveryView: React.FC = React.memo(() => { const prevCount = useAppStore.getState().discoveryRepos[channelId]?.length ?? 0; const currentAllRepos = useAppStore.getState().discoveryRepos[channelId] || []; + const persistedAnalyses = await discoveryAnalysisStorage.loadAllAnalyses(); const mergedRepos = result.repos.map((newRepo: DiscoveryRepo) => { const existingRepo = currentAllRepos.find((r: DiscoveryRepo) => r.id === newRepo.id); if (existingRepo && existingRepo.analyzed_at) { @@ -543,6 +545,17 @@ export const DiscoveryView: React.FC = React.memo(() => { analysis_failed: existingRepo.analysis_failed, }; } + const persisted = persistedAnalyses.get(newRepo.id); + if (persisted && persisted.analyzed_at) { + return { + ...newRepo, + ai_summary: persisted.ai_summary, + ai_tags: persisted.ai_tags, + ai_platforms: persisted.ai_platforms, + analyzed_at: persisted.analyzed_at, + analysis_failed: persisted.analysis_failed, + }; + } return newRepo; }); @@ -739,6 +752,13 @@ export const DiscoveryView: React.FC = React.memo(() => { analysis_failed: false, }; updateDiscoveryRepo(updatedRepo); + discoveryAnalysisStorage.saveAnalysis(updatedRepo.id, { + ai_summary: result.summary, + ai_tags: result.tags, + ai_platforms: result.platforms, + analyzed_at: updatedRepo.analyzed_at, + analysis_failed: false, + }); } else if (!result.success && result.repo) { const failedRepo: DiscoveryRepo = { ...result.repo, @@ -749,6 +769,10 @@ export const DiscoveryView: React.FC = React.memo(() => { analysis_failed: true, }; updateDiscoveryRepo(failedRepo); + discoveryAnalysisStorage.saveAnalysis(failedRepo.id, { + analyzed_at: failedRepo.analyzed_at, + analysis_failed: true, + }); } } ); diff --git a/src/services/discoveryAnalysisStorage.ts b/src/services/discoveryAnalysisStorage.ts new file mode 100644 index 0000000..821b18f --- /dev/null +++ b/src/services/discoveryAnalysisStorage.ts @@ -0,0 +1,157 @@ +export interface DiscoveryAnalysisData { + ai_summary?: string; + ai_tags?: string[]; + ai_platforms?: string[]; + analyzed_at?: string; + analysis_failed?: boolean; +} + +const DB_NAME = 'github-stars-discovery-analysis'; +const STORE_NAME = 'analysis'; +const DB_VERSION = 1; + +const canUseIndexedDB = (): boolean => + typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined'; + +const openDb = (): Promise => { + return new Promise((resolve, reject) => { + const request = window.indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +}; + +const withTimeout = async (promise: Promise, timeoutMs = 3000): Promise => { + return await Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('DiscoveryAnalysisStorage timeout')), timeoutMs) + ), + ]); +}; + +export const discoveryAnalysisStorage = { + async saveAnalysis(repoId: number, data: DiscoveryAnalysisData): Promise { + if (!canUseIndexedDB()) return; + try { + const db = await withTimeout(openDb()); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).put(JSON.stringify(data), repoId); + + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); + } catch (e) { + console.warn('[discoveryAnalysisStorage] saveAnalysis failed:', e); + } + }, + + async loadAnalysis(repoId: number): Promise { + if (!canUseIndexedDB()) return null; + try { + const db = await withTimeout(openDb()); + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const req = tx.objectStore(STORE_NAME).get(repoId); + + req.onsuccess = () => { + const raw = req.result as string | undefined; + if (!raw) { + resolve(null); + return; + } + try { + resolve(JSON.parse(raw) as DiscoveryAnalysisData); + } catch { + resolve(null); + } + }; + req.onerror = () => reject(req.error); + tx.oncomplete = () => db.close(); + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); + } catch (e) { + console.warn('[discoveryAnalysisStorage] loadAnalysis failed:', e); + return null; + } + }, + + async loadAllAnalyses(): Promise> { + const result = new Map(); + if (!canUseIndexedDB()) return result; + + try { + const db = await withTimeout(openDb()); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const req = tx.objectStore(STORE_NAME).openCursor(); + + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) { + resolve(); + return; + } + const key = cursor.key as number; + const raw = cursor.value as string; + try { + result.set(key, JSON.parse(raw) as DiscoveryAnalysisData); + } catch { + // skip corrupted entries + } + cursor.continue(); + }; + req.onerror = () => reject(req.error); + tx.oncomplete = () => db.close(); + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); + } catch (e) { + console.warn('[discoveryAnalysisStorage] loadAllAnalyses failed:', e); + } + + return result; + }, + + async deleteAnalysis(repoId: number): Promise { + if (!canUseIndexedDB()) return; + try { + const db = await withTimeout(openDb()); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).delete(repoId); + + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); + } catch (e) { + console.warn('[discoveryAnalysisStorage] deleteAnalysis failed:', e); + } + }, +}; From 0a7e6405fd604bfe2a76326dc92b14f1b84aa0a1 Mon Sep 17 00:00:00 2001 From: HappySummer Date: Fri, 24 Apr 2026 04:45:54 +0800 Subject: [PATCH 7/7] =?UTF-8?q?style(SubscriptionRepoCard):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=8D=A1=E7=89=87=E6=82=AC=E5=81=9C=E6=95=88=E6=9E=9C?= =?UTF-8?q?=E5=92=8C=E5=9C=86=E8=A7=92=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调整卡片悬停时的阴影、边框和位移效果,统一桌面安全模式和非安全模式下的交互体验 --- src/components/SubscriptionRepoCard.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/SubscriptionRepoCard.tsx b/src/components/SubscriptionRepoCard.tsx index bab945b..836a11b 100644 --- a/src/components/SubscriptionRepoCard.tsx +++ b/src/components/SubscriptionRepoCard.tsx @@ -281,10 +281,10 @@ export const SubscriptionRepoCard: React.FC = ({ repo <>
e.preventDefault()}