From 0fceeeb2c63c654a6f6e0b604f6ece4db3d8b192 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:11:43 +0800 Subject: [PATCH 01/23] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96UI=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E6=A0=B7=E5=BC=8F=E5=92=8C=E4=BA=A4=E4=BA=92=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(RepositoryCard): 增强工具提示在浅色模式下的可读性 feat(ReadmeModal): 添加字体大小类型支持 style(index.css): 改进文本区域和输入框的浅色模式样式 refactor(MarkdownRenderer): 移除行号显示并支持字体大小调整 refactor(RepositoryEditModal): 优化浅色模式下的表单样式和交互 --- src/components/MarkdownRenderer.tsx | 66 +++++-------- src/components/ReadmeModal.tsx | 13 +++ src/components/RepositoryCard.tsx | 16 +-- src/components/RepositoryEditModal.tsx | 129 +++++++++++++------------ src/index.css | 103 ++++++++++++++++++++ 5 files changed, 214 insertions(+), 113 deletions(-) diff --git a/src/components/MarkdownRenderer.tsx b/src/components/MarkdownRenderer.tsx index 00f8e3c2..b722eb1d 100644 --- a/src/components/MarkdownRenderer.tsx +++ b/src/components/MarkdownRenderer.tsx @@ -18,6 +18,7 @@ interface MarkdownRendererProps { enableHtml?: boolean; baseUrl?: string; headingIds?: Map; + fontSize?: 'small' | 'medium' | 'large'; } const REMARK_PLUGINS = [remarkGfm, remarkBreaks]; @@ -92,10 +93,6 @@ const CodeBlock: React.FC<{ } }, [codeText, uiLanguage]); - const codeLines = codeText.split('\n'); - const lineCount = codeLines.length; - const showLineNumbers = lineCount > 3; - const isBashLike = ['bash', 'sh', 'shell', 'zsh'].includes(normalizedLanguage); const isPowerShell = ['powershell', 'ps1'].includes(normalizedLanguage); const isCmdLike = ['cmd', 'bat'].includes(normalizedLanguage); @@ -141,11 +138,6 @@ const CodeBlock: React.FC<{ )}
- {showLineNumbers && ( - - {lineCount} {uiLanguage === 'zh' ? '行' : 'lines'} - - )}
); @@ -481,7 +448,9 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }> setIsLoading(false); }, []); - const handleRetry = useCallback(() => { + const handleRetry = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); setHasError(false); setIsLoading(true); setImageSizeKnown(false); @@ -784,7 +753,8 @@ const MarkdownRenderer: React.FC = memo(({ shouldRender = true, enableHtml = false, baseUrl, - headingIds + headingIds, + fontSize = 'medium' }) => { const headingCounterRef = useRef(headingIds?.size ?? 0); const headingTextCountMapRef = useRef(new Map()); @@ -796,6 +766,18 @@ const MarkdownRenderer: React.FC = memo(({ const rehypePlugins = enableHtml ? REHYPE_PLUGINS_WITH_HTML : REHYPE_PLUGINS_NO_HTML; + const getProseClass = useCallback(() => { + switch (fontSize) { + case 'small': + return 'prose prose-xs dark:prose-invert'; + case 'large': + return 'prose prose-lg dark:prose-invert'; + case 'medium': + default: + return 'prose prose-sm dark:prose-invert'; + } + }, [fontSize]); + const getHeadingId = useCallback((children: React.ReactNode): string | undefined => { if (headingIds && headingIds.size > 0) { const text = extractTextFromChildren(children); @@ -933,7 +915,7 @@ const MarkdownRenderer: React.FC = memo(({ } return ( -
+
= ({ const currentFontSize = FONT_SIZES[fontSizeIndex].value; + const getFontSizeType = useCallback((): 'small' | 'medium' | 'large' => { + switch (fontSizeIndex) { + case 0: + return 'small'; + case 2: + return 'large'; + case 1: + default: + return 'medium'; + } + }, [fontSizeIndex]); + const extractToc = useCallback((content: string): { items: TocItem[], idMap: Map } => { const items: TocItem[] = []; const idMap = new Map(); @@ -477,6 +489,7 @@ export const ReadmeModal: React.FC = ({ enableHtml={true} baseUrl={repository?.html_url} headingIds={headingIdMap} + fontSize={getFontSizeType()} /> ) : (
diff --git a/src/components/RepositoryCard.tsx b/src/components/RepositoryCard.tsx index 71ce7297..f02243c4 100644 --- a/src/components/RepositoryCard.tsx +++ b/src/components/RepositoryCard.tsx @@ -873,28 +873,28 @@ const RepositoryCardComponent: React.FC = ({
- {/* Description with Tooltip */} + {/* Description with Tooltip - Enhanced for Light Mode */}
isTextTruncated && setShowTooltip(true)} onMouseLeave={() => setShowTooltip(false)} >

{highlightSearchTerm(displayContent.content, searchQuery)}

- {/* Tooltip - Only show when text is actually truncated */} + {/* Enhanced Tooltip - Optimized for Light Mode Readability */} {isTextTruncated && showTooltip && ( -
-
+
+
{highlightSearchTerm(displayContent.content, searchQuery)}
- {/* Arrow */} -
+ {/* Arrow with Light Mode Optimization */} +
)}
diff --git a/src/components/RepositoryEditModal.tsx b/src/components/RepositoryEditModal.tsx index 4e78de2f..cd6e56ec 100644 --- a/src/components/RepositoryEditModal.tsx +++ b/src/components/RepositoryEditModal.tsx @@ -54,7 +54,7 @@ export const RepositoryEditModal: React.FC = ({ onClose, repository }) => { - const { updateRepository, language, customCategories, hiddenDefaultCategoryIds, defaultCategoryOverrides } = useAppStore(); + const { updateRepository, language, customCategories, hiddenDefaultCategoryIds, defaultCategoryOverrides, theme } = useAppStore(); const [formData, setFormData] = useState({ description: '', @@ -589,12 +589,15 @@ export const RepositoryEditModal: React.FC = ({ if (!repository) return null; - // 统一的卡片样式 - const sectionClass = "p-5 bg-white dark:bg-panel-dark rounded-xl border border-black/[0.06] dark:border-white/[0.04]"; - const labelClass = "flex items-center space-x-2 text-sm font-medium text-gray-900 dark:text-text-primary mb-3"; - const inputClass = "w-full px-3 py-2 bg-light-bg dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary placeholder-gray-400 dark:placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-brand-violet focus:border-transparent transition-all"; - const buttonSecondaryClass = "flex items-center space-x-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition-all"; - const tagClass = "inline-flex items-center px-2.5 py-1 bg-gray-100 text-gray-700 dark:bg-white/[0.04] dark:text-text-secondary border-transparent rounded-md text-sm border border-black/[0.06] dark:border-white/[0.04] dark:border-white/[0.04]"; + // Unified card styles with enhanced light mode optimization + const sectionClass = "p-5 bg-white dark:bg-panel-dark rounded-xl border border-gray-200/80 dark:border-white/[0.04] shadow-sm"; + const labelClass = "flex items-center space-x-2 text-[13px] font-medium text-gray-900 dark:text-text-primary mb-3"; + const inputClass = "w-full px-4 py-3 bg-gray-50/50 dark:bg-white/[0.04] border border-gray-200 dark:border-white/[0.04] rounded-xl text-gray-900 dark:text-text-primary placeholder-gray-400 dark:placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-brand-violet/30 focus:border-brand-violet dark:focus:ring-brand-violet/50 dark:focus:border-brand-violet transition-all duration-200 hover:bg-gray-100/50 dark:hover:bg-white/[0.06] hover:border-gray-300 dark:hover:border-white/[0.08] text-[13px] leading-[1.625]"; + const textareaClass = `${inputClass} resize-y min-h-[120px] max-h-[400px] overflow-y-auto scrollbar-auto`; + const buttonSecondaryClass = "flex items-center space-x-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition-all duration-200"; + const tagClass = "inline-flex items-center px-2.5 py-1 bg-gray-100 text-gray-700 dark:bg-white/[0.04] dark:text-text-secondary rounded-md text-sm border border-gray-200/80 dark:border-white/[0.04]"; + const infoBoxClass = "mt-3 p-3.5 bg-gradient-to-br from-gray-50 to-white dark:from-white/[0.02] dark:to-white/[0.04] border border-gray-200/80 dark:border-white/[0.04] rounded-xl text-[12px] leading-[1.5] transition-all duration-200"; + const infoTextClass = "text-gray-700 dark:text-text-secondary flex items-start"; return ( = ({ setFormData(prev => ({ ...prev, description: e.target.value })); setEditIntent(prev => ({ ...prev, description: 'keep-custom' })); }} - className={`${inputClass} resize-none`} - rows={3} + className={textareaClass} + rows={5} placeholder={t('输入自定义描述...', 'Enter custom description...')} /> - {/* Save Effect Info - always visible */} + {/* Save Effect Info - Enhanced for Light Mode */} {editIntent.description === 'clear' ? ( -
-

- +

+

+ {t( '描述已清空,保存后将显示"(无描述)"。即使有AI总结或原始描述也不会显示。', @@ -697,9 +700,9 @@ export const RepositoryEditModal: React.FC = ({

) : editIntent.description === 'reset-to-ai' ? ( -
-

- +

+

+ {t( '保存后将清除自定义描述,显示AI总结。如果AI重新分析,描述可能随之变化。', @@ -709,9 +712,9 @@ export const RepositoryEditModal: React.FC = ({

) : editIntent.description === 'reset-to-original' ? ( -
-

- +

+

+ {t( '保存后将清除自定义描述,显示GitHub原始描述。', @@ -721,9 +724,9 @@ export const RepositoryEditModal: React.FC = ({

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

- +

+

+ {repository?.ai_summary ? t('当前编辑为空,保存后将显示AI总结。如需清空请点击"清除描述"。', 'Currently empty. AI summary will be shown after saving. Click "Clear" to explicitly clear.') @@ -734,9 +737,9 @@ export const RepositoryEditModal: React.FC = ({

) : editIntent.description === 'keep-custom' && customStatus.description ? ( -
-

- +

+

+ {t( '保存后将使用此自定义描述,优先级高于AI总结和原始描述。', @@ -746,9 +749,9 @@ export const RepositoryEditModal: React.FC = ({

) : editIntent.description === 'keep-custom' && formData.description.trim() !== '' && !customStatus.description ? ( -
-

- +

+

+ {t( '当前内容与AI总结或原始描述一致,保存后将使用自动推断的来源。', @@ -809,10 +812,10 @@ export const RepositoryEditModal: React.FC = ({

- {/* Feature Tip */} -
-

- + {/* Feature Tip - Enhanced */} +

+

+ {t( '描述优先级:自定义描述 > AI总结 > 原始描述。"重置"会清除自定义并回退到对应来源,"清除"会明确清空描述(不显示任何来源)。', @@ -906,11 +909,11 @@ export const RepositoryEditModal: React.FC = ({

- {/* Custom Category Selection Info */} + {/* Custom Category Selection Info - Enhanced */} {editIntent.category === 'keep-custom' && formData.category && ( -
-

- +

+

+ {t( '已选择自定义分类。保存后仓库将固定显示在此分类中,不会随AI分析结果自动变化。建议同时开启分类锁定以防止同步时被覆盖。', @@ -921,11 +924,11 @@ export const RepositoryEditModal: React.FC = ({

)} - {/* Reset Category Info */} + {/* Reset Category Info - Enhanced */} {editIntent.category === 'reset-to-ai' && ( -
-

- +

+

+ {t( '重置为AI分类将清除自定义分类设置,系统会根据AI标签自动推断分类。如果AI标签变化,分类可能会随之改变。', @@ -937,9 +940,9 @@ export const RepositoryEditModal: React.FC = ({ )} {editIntent.category === 'reset-to-original' && ( -

-

- +

+

+ {t( '重置为默认分类将清除自定义分类设置,系统会根据仓库信息(名称、描述、语言等)自动匹配分类。', @@ -950,11 +953,11 @@ export const RepositoryEditModal: React.FC = ({

)} - {/* Clear Category Warning */} + {/* Clear Category Warning - Enhanced */} {editIntent.category === 'clear' && ( -
-

- +

+

+ {t( '清除分类后,仓库将不再有明确的分类归属。系统会尝试根据AI标签自动匹配分类,如果没有匹配到则可能显示在默认分类中。', @@ -965,8 +968,8 @@ export const RepositoryEditModal: React.FC = ({

)} - {/* Category Lock */} -
+ {/* Category Lock - Enhanced */} +
{formData.categoryLocked && formData.category ? ( @@ -1097,38 +1100,38 @@ export const RepositoryEditModal: React.FC = ({
- {/* Status Alert */} + {/* Status Alert - Enhanced */} {formData.tags.length === 0 && ( -
-

+

+

{editIntent.tags === 'clear' ? ( <> - ⚠️ + ⚠️ {t('标签已清空。保存后将不显示任何标签。', 'Tags cleared. No tags will be shown after saving.')} ) : editIntent.tags === 'reset-to-ai' ? ( <> - + {t('将显示AI标签。', 'AI tags will be shown.')} ) : editIntent.tags === 'reset-to-original' ? ( <> - + {t('将显示GitHub Topics。', 'GitHub Topics will be shown.')} ) : repository?.ai_tags && repository.ai_tags.length > 0 ? ( <> - ⚠️ + ⚠️ {t('当前无自定义标签。保存后将显示AI标签。', 'No custom tags. AI tags will be shown after saving.')} ) : repository?.topics && repository.topics.length > 0 ? ( <> - ⚠️ + ⚠️ {t('当前无自定义标签。保存后将显示GitHub Topics。', 'No custom tags. GitHub Topics will be shown after saving.')} ) : ( <> - ⚠️ + ⚠️ {t('无可用标签。', 'No tags available.')} )} @@ -1160,17 +1163,17 @@ export const RepositoryEditModal: React.FC = ({

- {/* Action Buttons */} -
+ {/* Action Buttons - Enhanced */} +
{isOpen && ( -
+
{sortOptions.map((option) => (
-
-

{t('关于AI搜索', 'About AI Search')}

-

- {activeAIConfig - ? t( - '已配置AI服务时,将调用AI进行语义搜索和智能重排序。未配置时使用本地算法根据仓库名称、描述、标签等多维度进行匹配和排序。', - 'When AI service is configured, it will be called for semantic search and intelligent reranking. Otherwise, local algorithms are used to match and rank based on repository name, description, tags, and other dimensions.' - ) - : t( - '此功能使用本地算法进行智能排序。配置AI服务后可启用语义搜索功能,获得更精准的搜索结果。', - 'This feature uses local algorithms for intelligent ranking. Configure an AI service to enable semantic search for more accurate results.' - )} +

+

{t('关于AI搜索', 'About AI Search')}

+

+ {t( + '使用多维度智能加权算法进行搜索和排序:仓库名称(40%)、描述(32%)、标签(25%)、AI摘要(15%)、平台语言(18%)等维度综合评分,支持精确匹配加成和流行度权重。', + 'Uses multi-dimensional intelligent weighted algorithm for search and ranking: repository name (40%), description (32%), tags (25%), AI summary (15%), platform/language (18%) and other dimensions with exact match bonus and popularity weighting.' + )}

-
+
@@ -975,7 +970,7 @@ export const SearchBar: React.FC = () => {
{/* Sort Controls */} -
+
setSearchFilters({ sortBy: value as 'stars' | 'updated' | 'name' | 'starred' })} From 77f1fb0b3ace2399dd5bf38663f016e26bbbbd17 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:32:52 +0800 Subject: [PATCH 04/23] =?UTF-8?q?feat(store):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E7=8A=B6=E6=80=81=E6=B0=B4=E5=90=88=E6=A3=80?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加 hasHydrated 状态用于检测 store 是否完成水合 在 App 组件中添加加载状态以确保主题正确应用 更新搜索算法权重描述文案 --- src/App.tsx | 18 +++++++++++++++--- src/components/SearchBar.tsx | 4 ++-- src/store/useAppStore.ts | 13 +++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6e1f8717..3d25a9da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,11 +51,12 @@ const SettingsView = React.memo(() => ); SettingsView.displayName = 'SettingsView'; function App() { - const { - isAuthenticated, - currentView, + const { + isAuthenticated, + currentView, selectedCategory, theme, + hasHydrated, searchResults, repositories, setSelectedCategory, @@ -71,6 +72,17 @@ function App() { } }, [theme]); + // Show loading state while store is hydrating to ensure correct theme is applied + if (!hasHydrated) { + return ( +
+
+ Loading... +
+
+ ); + } + useEffect(() => { let unsubscribe: (() => void) | null = null; let cancelled = false; diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 230c7d85..5065f7eb 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -903,8 +903,8 @@ export const SearchBar: React.FC = () => {

{t('关于AI搜索', 'About AI Search')}

{t( - '使用多维度智能加权算法进行搜索和排序:仓库名称(40%)、描述(32%)、标签(25%)、AI摘要(15%)、平台语言(18%)等维度综合评分,支持精确匹配加成和流行度权重。', - 'Uses multi-dimensional intelligent weighted algorithm for search and ranking: repository name (40%), description (32%), tags (25%), AI summary (15%), platform/language (18%) and other dimensions with exact match bonus and popularity weighting.' + '多维度智能加权算法(累积得分制):名称(0.40分)、路径(0.35分)、自定义描述(0.32分)、描述(0.30分)、Topics(0.25分)、自定义标签(0.24分)、AI标签(0.22分)、平台(0.18分)、AI摘要(0.15分)、语言(0.12分)。支持精确匹配加分(+0.50/+0.30)和流行度加成。', + 'Multi-dimensional weighted algorithm (cumulative scoring): name(0.40), path(0.35), custom desc(0.32), desc(0.30), topics(0.25), custom tags(0.24), AI tags(0.22), platform(0.18), AI summary(0.15), language(0.12). Supports exact match bonus (+0.50/+0.30) and popularity boost.' )}

diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 9912a231..5e380ded 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -145,6 +145,9 @@ interface AppActions { setLanguage: (language: 'zh' | 'en') => void; setSidebarCollapsed: (collapsed: boolean) => void; setReadmeModalOpen: (open: boolean) => void; + + // Hydration state + setHasHydrated: (hydrated: boolean) => void; // Update actions setUpdateNotification: (notification: UpdateNotification | null) => void; @@ -643,6 +646,7 @@ export const useAppStore = create()( collapsedSidebarCategoryCount: 20, assetFilters: defaultPresetFilters, theme: 'dark', + hasHydrated: false, currentView: 'repositories', selectedCategory: 'all', language: 'zh', @@ -1150,6 +1154,9 @@ export const useAppStore = create()( setLanguage: (language) => set({ language }), setSidebarCollapsed: (isSidebarCollapsed) => set({ isSidebarCollapsed }), setReadmeModalOpen: (readmeModalOpen) => set({ readmeModalOpen }), + + // Hydration state + setHasHydrated: (hasHydrated) => set({ hasHydrated }), // Update actions setUpdateNotification: (notification) => set({ updateNotification: notification }), @@ -1488,6 +1495,12 @@ export const useAppStore = create()( ...normalized, }; }, + onRehydrateStorage: () => (state) => { + console.log('Store hydration complete'); + if (state) { + state.setHasHydrated(true); + } + }, } ) ); From 2fc71588448dc6ef5979aaf37ac16938d3dd3b3c Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:40:29 +0800 Subject: [PATCH 05/23] =?UTF-8?q?feat(SearchBar):=20=E6=A0=B9=E6=8D=AEAI?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=A8=E6=80=81=E6=98=BE=E7=A4=BA=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=A8=A1=E5=BC=8F=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根据activeAIConfig状态动态显示不同的搜索模式说明,当启用AI配置时显示语义搜索模式说明,否则显示本地智能排序说明 --- src/components/SearchBar.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 5065f7eb..5a295d13 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -900,11 +900,16 @@ export const SearchBar: React.FC = () => {
-

{t('关于AI搜索', 'About AI Search')}

+

+ {t('关于AI搜索', 'About AI Search')} +

- {t( - '多维度智能加权算法(累积得分制):名称(0.40分)、路径(0.35分)、自定义描述(0.32分)、描述(0.30分)、Topics(0.25分)、自定义标签(0.24分)、AI标签(0.22分)、平台(0.18分)、AI摘要(0.15分)、语言(0.12分)。支持精确匹配加分(+0.50/+0.30)和流行度加成。', - 'Multi-dimensional weighted algorithm (cumulative scoring): name(0.40), path(0.35), custom desc(0.32), desc(0.30), topics(0.25), custom tags(0.24), AI tags(0.22), platform(0.18), AI summary(0.15), language(0.12). Supports exact match bonus (+0.50/+0.30) and popularity boost.' + {activeAIConfig ? t( + 'AI语义搜索模式:使用配置的AI服务进行智能语义理解和重排序。AI将分析查询意图,理解上下文关系,并提供语义相关的搜索结果。支持自然语言查询和概念匹配。', + 'AI semantic search mode: Uses configured AI service for intelligent semantic understanding and reranking. AI analyzes query intent, understands context, and provides semantically relevant results. Supports natural language queries and concept matching.' + ) : t( + '本地智能排序(累积得分制):名称(0.40分)、路径(0.35分)、自定义描述(0.32分)、描述(0.30分)、Topics(0.25分)、自定义标签(0.24分)、AI标签(0.22分)、平台(0.18分)、AI摘要(0.15分)、语言(0.12分)。支持精确匹配加分(+0.50/+0.30)和流行度加成。', + 'Local intelligent ranking (cumulative scoring): name(0.40), path(0.35), custom desc(0.32), desc(0.30), topics(0.25), custom tags(0.24), AI tags(0.22), platform(0.18), AI summary(0.15), language(0.12). Supports exact match bonus (+0.50/+0.30) and popularity boost.' )}

From 6b056a95b243230a5ec48ba37813f2328631e2a2 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:44:49 +0800 Subject: [PATCH 06/23] =?UTF-8?q?refactor(App):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E7=8A=B6=E6=80=81=E6=B8=B2=E6=9F=93=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E4=BB=A5=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81=E7=BB=93?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将加载状态的渲染逻辑移动到useEffect之后,使组件初始化逻辑更清晰 --- src/App.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3d25a9da..e57d0279 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -72,17 +72,6 @@ function App() { } }, [theme]); - // Show loading state while store is hydrating to ensure correct theme is applied - if (!hasHydrated) { - return ( -
-
- Loading... -
-
- ); - } - useEffect(() => { let unsubscribe: (() => void) | null = null; let cancelled = false; @@ -111,6 +100,17 @@ function App() { }; }, []); + // Show loading state while store is hydrating to ensure correct theme is applied + if (!hasHydrated) { + return ( +
+
+ Loading... +
+
+ ); + } + const handleCategorySelect = useCallback((category: string) => { setSelectedCategory(category); }, [setSelectedCategory]); From 87a7138c5746e06bbe9bef031afd6890b6ce9f54 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:27:20 +0800 Subject: [PATCH 07/23] =?UTF-8?q?refactor(App):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E7=8A=B6=E6=80=81=E5=92=8C=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=9A=84=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将加载状态的渲染逻辑移到认证检查之前,确保主题正确应用后再进行认证检查 --- src/App.tsx | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e57d0279..36016a72 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -100,17 +100,6 @@ function App() { }; }, []); - // Show loading state while store is hydrating to ensure correct theme is applied - if (!hasHydrated) { - return ( -
-
- Loading... -
-
- ); - } - const handleCategorySelect = useCallback((category: string) => { setSelectedCategory(category); }, [setSelectedCategory]); @@ -119,7 +108,7 @@ function App() { switch (currentView) { case 'repositories': return ( - +
+ Loading... +
+
+ ); + } + if (!isAuthenticated) { return ; } From 856cf8e87529ab38f5028ec57dd9ccb9937b61b6 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:28:37 +0800 Subject: [PATCH 08/23] =?UTF-8?q?fix(SearchBar):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=A8=A1=E5=BC=8F=E6=8F=8F=E8=BF=B0=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E4=B8=BA=E5=9B=9E=E9=80=80=E6=A8=A1=E5=BC=8F=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SearchBar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 5a295d13..21985539 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -908,8 +908,8 @@ export const SearchBar: React.FC = () => { 'AI语义搜索模式:使用配置的AI服务进行智能语义理解和重排序。AI将分析查询意图,理解上下文关系,并提供语义相关的搜索结果。支持自然语言查询和概念匹配。', 'AI semantic search mode: Uses configured AI service for intelligent semantic understanding and reranking. AI analyzes query intent, understands context, and provides semantically relevant results. Supports natural language queries and concept matching.' ) : t( - '本地智能排序(累积得分制):名称(0.40分)、路径(0.35分)、自定义描述(0.32分)、描述(0.30分)、Topics(0.25分)、自定义标签(0.24分)、AI标签(0.22分)、平台(0.18分)、AI摘要(0.15分)、语言(0.12分)。支持精确匹配加分(+0.50/+0.30)和流行度加成。', - 'Local intelligent ranking (cumulative scoring): name(0.40), path(0.35), custom desc(0.32), desc(0.30), topics(0.25), custom tags(0.24), AI tags(0.22), platform(0.18), AI summary(0.15), language(0.12). Supports exact match bonus (+0.50/+0.30) and popularity boost.' + '回退模式:基础文本搜索与默认排序。当未配置AI服务时,系统将使用基础文本匹配进行搜索(支持名称、描述、标签、语言等字段),并应用标准的排序和过滤控制。此为轻量级搜索方案,无语义理解能力。', + 'Fallback mode: Basic text search with default sorting. When no AI service is configured, the system uses basic text matching for search (supports name, description, tags, language, etc.) and applies standard sort and filter controls. This is a lightweight search solution without semantic understanding capabilities.' )}

From e5ba02cf3094c6dcbd79956b8f651c2f60f18bc4 Mon Sep 17 00:00:00 2001 From: HappySummer <141414769+SummerRay160@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:37:22 +0800 Subject: [PATCH 09/23] Update src/store/useAppStore.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/store/useAppStore.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 5e380ded..e06afd63 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -1495,11 +1495,13 @@ export const useAppStore = create()( ...normalized, }; }, - onRehydrateStorage: () => (state) => { - console.log('Store hydration complete'); - if (state) { - state.setHasHydrated(true); + onRehydrateStorage: (state) => (_rehydratedState, error) => { + if (error) { + console.error('Store hydration failed', error); + } else { + console.log('Store hydration complete'); } + state.setHasHydrated(true); }, } ) From f0dc7dc1563b16d8ea30317ea31a75b92cd75a84 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:25:34 +0800 Subject: [PATCH 10/23] =?UTF-8?q?feat(=E7=BF=BB=E8=AF=91):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0Markdown=E6=96=87=E6=A1=A3=E7=BF=BB=E8=AF=91=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现文档翻译核心功能,包括: 1. 添加query-string依赖处理URL参数 2. 创建翻译服务模块调用微软翻译API 3. 实现Markdown分割与重组工具 4. 在ReadmeModal中添加翻译UI控件 5. 创建useMarkdownTranslation自定义hook管理翻译状态 --- package-lock.json | 62 ++++++- package.json | 2 + src/components/ReadmeModal.tsx | 79 ++++++++- src/hooks/useMarkdownTranslation.ts | 181 +++++++++++++++++++ src/services/translateService.ts | 259 ++++++++++++++++++++++++++++ src/utils/markdownSplitter.ts | 235 +++++++++++++++++++++++++ 6 files changed, 812 insertions(+), 6 deletions(-) create mode 100644 src/hooks/useMarkdownTranslation.ts create mode 100644 src/services/translateService.ts create mode 100644 src/utils/markdownSplitter.ts diff --git a/package-lock.json b/package-lock.json index a6ae5375..c46a2d2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "github-stars-manager", - "version": "0.5.4", + "version": "0.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-stars-manager", - "version": "0.5.4", + "version": "0.5.5", "dependencies": { + "@types/query-string": "^6.2.0", "date-fns": "^3.3.1", "highlight.js": "^11.11.1", "lucide-react": "^0.344.0", + "query-string": "^9.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -1946,6 +1948,12 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/query-string": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@types/query-string/-/query-string-6.2.0.tgz", + "integrity": "sha512-dnYqKg7eZ+t7ZhCuBtwLxjqON8yXr27hiu3zXfPqxfJSbWUZNwwISE0BJUxghlcKsk4lZSp7bdFSJBJVNWBfmA==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -4666,6 +4674,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decode-uri-component": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", + "integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -5314,6 +5331,18 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz", + "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8566,6 +8595,23 @@ "node": ">=6" } }, + "node_modules/query-string": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-9.3.1.tgz", + "integrity": "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.4.1", + "filter-obj": "^5.1.0", + "split-on-first": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -9407,6 +9453,18 @@ "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", "dev": true }, + "node_modules/split-on-first": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz", + "integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/package.json b/package.json index e9311357..efe93c2f 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,11 @@ "build:all": "npm run build && npm run build:server" }, "dependencies": { + "@types/query-string": "^6.2.0", "date-fns": "^3.3.1", "highlight.js": "^11.11.1", "lucide-react": "^0.344.0", + "query-string": "^9.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/src/components/ReadmeModal.tsx b/src/components/ReadmeModal.tsx index ea20043a..09354837 100644 --- a/src/components/ReadmeModal.tsx +++ b/src/components/ReadmeModal.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { X, Loader2, AlertCircle, FileText, ExternalLink, List, Type, ArrowUp } from 'lucide-react'; +import { X, Loader2, AlertCircle, FileText, ExternalLink, List, Type, ArrowUp, Languages, RotateCcw } from 'lucide-react'; import MarkdownRenderer from './MarkdownRenderer'; import { stripMarkdownFormatting } from '../utils/markdownUtils'; import { Repository } from '../types'; import { GitHubApiService } from '../services/githubApi'; import { backend } from '../services/backendAdapter'; import { useAppStore } from '../store/useAppStore'; +import { useMarkdownTranslation } from '../hooks/useMarkdownTranslation'; interface TocItem { id: string; @@ -49,6 +50,18 @@ export const ReadmeModal: React.FC = ({ const previousFocusRef = useRef(null); const abortControllerRef = useRef(null); + const { + status: translateStatus, + progress: translateProgress, + error: translateError, + translatedContent, + translate, + revert, + reset: resetTranslation, + } = useMarkdownTranslation({ + targetLanguage: language, + }); + const currentFontSize = FONT_SIZES[fontSizeIndex].value; const getFontSizeType = useCallback((): 'small' | 'medium' | 'large' => { @@ -208,6 +221,19 @@ export const ReadmeModal: React.FC = ({ const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); + const handleTranslate = useCallback(async () => { + if (translateStatus === 'translating') return; + if (readmeContent) { + await translate(readmeContent); + } + }, [readmeContent, translate, translateStatus]); + + const handleRevertTranslation = useCallback(() => { + revert(); + }, [revert]); + + const displayContent = translatedContent || readmeContent; + const fetchReadme = useCallback(async () => { if (!repository) return; @@ -281,10 +307,11 @@ export const ReadmeModal: React.FC = ({ setScrollProgress(0); setShowBackToTop(false); setActiveHeadingId(null); + resetTranslation(); } else { setShowToc(true); } - }, [isOpen]); + }, [isOpen, resetTranslation]); useEffect(() => { return () => { @@ -390,6 +417,50 @@ export const ReadmeModal: React.FC = ({
+ {readmeContent && !loading && ( + translateStatus === 'translated' ? ( + + ) : ( + + ) + )} + {translateError && ( +
+ {translateError} +
+ )} {tocItems.length > 0 && (
- ) : readmeContent ? ( + ) : displayContent ? ( void; +} + +interface UseMarkdownTranslationResult { + status: TranslationStatus; + progress: { current: number; total: number }; + error: string | null; + translatedContent: string | null; + detectedLanguage: 'zh' | 'en' | 'unknown'; + translate: (content: string) => Promise; + revert: () => void; + clearError: () => void; + reset: () => void; +} + +export const useMarkdownTranslation = ( + options: UseMarkdownTranslationOptions +): UseMarkdownTranslationResult => { + const { targetLanguage, onProgress } = options; + + const [status, setStatus] = useState('idle'); + const [progress, setProgress] = useState({ current: 0, total: 0 }); + const [error, setError] = useState(null); + const [translatedContent, setTranslatedContent] = useState(null); + const [detectedLanguage, setDetectedLanguage] = useState<'zh' | 'en' | 'unknown'>('unknown'); + + const abortControllerRef = useRef(null); + const originalContentRef = useRef(null); + + const translate = useCallback( + async (content: string): Promise => { + if (status === 'translating') { + return null; + } + + originalContentRef.current = content; + + const detected = detectLanguage(content); + setDetectedLanguage(detected); + + if (detected === targetLanguage) { + setError(detected === 'zh' ? '内容已是中文' : 'Content is already in English'); + setStatus('error'); + return null; + } + + setStatus('translating'); + setError(null); + setProgress({ current: 0, total: 0 }); + + abortControllerRef.current = new AbortController(); + + try { + const { segments } = splitMarkdownForTranslation(content); + + const translatableSegments = segments.filter(s => s.type === 'translatable'); + + if (translatableSegments.length === 0) { + setStatus('translated'); + setTranslatedContent(content); + return content; + } + + const chunks = extractTranslatableChunks(segments); + + if (chunks.length === 0) { + setStatus('translated'); + setTranslatedContent(content); + return content; + } + + const totalSegments = translatableSegments.length; + setProgress({ current: 0, total: totalSegments }); + onProgress?.(0, totalSegments); + + const direction = getTranslateDirection(detected, targetLanguage); + + const chunkTexts = chunks.map(c => c.content); + + let completedCount = 0; + const results = await translateBatch( + chunkTexts, + direction.to, + direction.from, + abortControllerRef.current.signal + ); + + const translations = results.map((r) => r.translatedText); + + const translatedMap = mergeTranslatedChunks(chunks, translations); + + completedCount = translations.length; + setProgress({ current: completedCount, total: totalSegments }); + onProgress?.(completedCount, totalSegments); + + const result = reconstructMarkdown(segments, translatedMap); + + setStatus('translated'); + setTranslatedContent(result); + setProgress({ current: totalSegments, total: totalSegments }); + onProgress?.(totalSegments, totalSegments); + + return result; + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + setStatus('idle'); + return null; + } + + const errorMessage = + err instanceof Error ? err.message : 'Translation failed'; + setError(errorMessage); + setStatus('error'); + return null; + } + }, + [status, targetLanguage, onProgress] + ); + + const revert = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + setStatus('idle'); + setTranslatedContent(null); + setProgress({ current: 0, total: 0 }); + setError(null); + }, []); + + const clearError = useCallback(() => { + setError(null); + if (status === 'error') { + setStatus('idle'); + } + }, [status]); + + const reset = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + setStatus('idle'); + setTranslatedContent(null); + setProgress({ current: 0, total: 0 }); + setError(null); + setDetectedLanguage('unknown'); + originalContentRef.current = null; + }, []); + + return { + status, + progress, + error, + translatedContent, + detectedLanguage, + translate, + revert, + clearError, + reset, + }; +}; + +export const clearTranslationTokenCache = (): void => { + clearTranslateCache(); +}; diff --git a/src/services/translateService.ts b/src/services/translateService.ts new file mode 100644 index 00000000..461b151a --- /dev/null +++ b/src/services/translateService.ts @@ -0,0 +1,259 @@ +import queryString from 'query-string'; + +interface TranslateResult { + translatedText: string; + detectedLanguage: string; +} + +interface CachedToken { + token: string; + expiresAt: number; +} + +let cachedToken: CachedToken | null = null; +let tokenPromise: Promise | null = null; + +const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; +const TRANSLATE_API_URL = 'https://api-edge.cognitive.microsofttranslator.com/translate'; +const AUTH_URL = 'https://edge.microsoft.com/translate/auth'; + +const parseJwtExpiration = (token: string): number => { + try { + const parts = token.split('.'); + if (parts.length !== 3) return 0; + + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse(atob(base64)); + if (payload.exp) { + return payload.exp * 1000; + } + return 0; + } catch { + return 0; + } +}; + +const isTokenValid = (cached: CachedToken | null): boolean => { + if (!cached) return false; + return Date.now() < cached.expiresAt - TOKEN_REFRESH_BUFFER_MS; +}; + +const getStoredToken = (): CachedToken | null => { + try { + const stored = localStorage.getItem('ms_translate_token'); + if (!stored) return null; + + const parsed = JSON.parse(stored) as CachedToken; + if (isTokenValid(parsed)) { + return parsed; + } + localStorage.removeItem('ms_translate_token'); + return null; + } catch { + return null; + } +}; + +const storeToken = (token: string): void => { + try { + const expiresAt = parseJwtExpiration(token); + if (expiresAt > 0) { + cachedToken = { token, expiresAt }; + localStorage.setItem('ms_translate_token', JSON.stringify(cachedToken)); + } + } catch { + // ignore storage errors + } +}; + +export const apiMsAuth = async (): Promise => { + const storedToken = getStoredToken(); + if (storedToken) { + cachedToken = storedToken; + return storedToken.token; + } + + if (isTokenValid(cachedToken)) { + return cachedToken.token; + } + + if (tokenPromise) { + return tokenPromise; + } + + tokenPromise = (async () => { + try { + const response = await fetch(AUTH_URL, { + method: 'GET', + credentials: 'omit', + }); + + if (!response.ok) { + throw new Error(`Auth failed: ${response.status}`); + } + + const token = await response.text(); + storeToken(token); + return token; + } finally { + tokenPromise = null; + } + })(); + + return tokenPromise; +}; + +export interface TranslateOptions { + from?: string; + to: string; + text: string; + signal?: AbortSignal; +} + +export const translateText = async (options: TranslateOptions): Promise => { + const { from, to, text, signal } = options; + + if (!text || text.trim() === '') { + return { translatedText: text, detectedLanguage: '' }; + } + + const token = await apiMsAuth(); + + const params = queryString.stringify({ + from: from || '', + to, + 'api-version': '3.0', + }); + + const url = `${TRANSLATE_API_URL}?${params}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify([{ Text: text }]), + signal, + }); + + if (!response.ok) { + if (response.status === 401) { + cachedToken = null; + localStorage.removeItem('ms_translate_token'); + } + throw new Error(`Translation failed: ${response.status}`); + } + + const data = await response.json(); + + if (!Array.isArray(data) || data.length === 0) { + throw new Error('Invalid translation response'); + } + + const result = data[0]; + const translatedText = result.translations?.[0]?.text || text; + const detectedLanguage = result.detectedLanguage?.language || ''; + + return { + translatedText, + detectedLanguage, + }; +}; + +export const translateBatch = async ( + texts: string[], + to: string, + from?: string, + signal?: AbortSignal +): Promise => { + if (texts.length === 0) return []; + + if (texts.length === 1) { + const result = await translateText({ text: texts[0], to, from, signal }); + return [result]; + } + + const results: TranslateResult[] = []; + const batchSize = 100; + const maxChars = 50000; + + for (let i = 0; i < texts.length; i += batchSize) { + if (signal?.aborted) { + throw new Error('Aborted'); + } + + const batch = texts.slice(i, i + batchSize); + let currentBatch: string[] = []; + let currentLength = 0; + + for (const text of batch) { + if (currentLength + text.length > maxChars && currentBatch.length > 0) { + const batchResults = await translateBatchInternal(currentBatch, to, from, signal); + results.push(...batchResults); + currentBatch = []; + currentLength = 0; + } + currentBatch.push(text); + currentLength += text.length; + } + + if (currentBatch.length > 0) { + const batchResults = await translateBatchInternal(currentBatch, to, from, signal); + results.push(...batchResults); + } + } + + return results; +}; + +const translateBatchInternal = async ( + texts: string[], + to: string, + from?: string, + signal?: AbortSignal +): Promise => { + const token = await apiMsAuth(); + + const params = queryString.stringify({ + from: from || '', + to, + 'api-version': '3.0', + }); + + const url = `${TRANSLATE_API_URL}?${params}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(texts.map(t => ({ Text: t }))), + signal, + }); + + if (!response.ok) { + if (response.status === 401) { + cachedToken = null; + localStorage.removeItem('ms_translate_token'); + } + throw new Error(`Translation failed: ${response.status}`); + } + + const data = await response.json(); + + if (!Array.isArray(data) || data.length !== texts.length) { + throw new Error('Invalid translation response'); + } + + return data.map((result, index) => ({ + translatedText: result.translations?.[0]?.text || texts[index], + detectedLanguage: result.detectedLanguage?.language || '', + })); +}; + +export const clearTranslateCache = (): void => { + cachedToken = null; + localStorage.removeItem('ms_translate_token'); +}; diff --git a/src/utils/markdownSplitter.ts b/src/utils/markdownSplitter.ts new file mode 100644 index 00000000..4cacf104 --- /dev/null +++ b/src/utils/markdownSplitter.ts @@ -0,0 +1,235 @@ +export interface MarkdownSegment { + id: number; + type: 'translatable' | 'code_block' | 'inline_code' | 'link' | 'image'; + content: string; +} + +interface ParsedContent { + segments: MarkdownSegment[]; +} + +let segmentIdCounter = 0; + +export const splitMarkdownForTranslation = (markdown: string): ParsedContent => { + segmentIdCounter = 0; + const segments: MarkdownSegment[] = []; + + const combinedRegex = /(```[\s\S]*?```)|(`[^`\n]+`)|(!\[[^\]]*\]\([^)]+\))|(\[[^\]]*\]\([^)]+\))/g; + + let lastIndex = 0; + let match: RegExpExecArray | null; + let pendingPrefix = ''; + + while ((match = combinedRegex.exec(markdown)) !== null) { + if (match.index > lastIndex) { + const textBetween = markdown.substring(lastIndex, match.index); + if (textBetween.trim()) { + segments.push({ + id: segmentIdCounter++, + type: 'translatable', + content: textBetween, + }); + } else { + pendingPrefix += textBetween; + } + } + + const fullMatch = match[0]; + const contentWithPrefix = pendingPrefix + fullMatch; + pendingPrefix = ''; + + if (match[1]) { + segments.push({ + id: segmentIdCounter++, + type: 'code_block', + content: contentWithPrefix, + }); + } else if (match[2]) { + segments.push({ + id: segmentIdCounter++, + type: 'inline_code', + content: contentWithPrefix, + }); + } else if (match[3]) { + segments.push({ + id: segmentIdCounter++, + type: 'image', + content: contentWithPrefix, + }); + } else if (match[4]) { + segments.push({ + id: segmentIdCounter++, + type: 'link', + content: contentWithPrefix, + }); + } + + lastIndex = match.index + fullMatch.length; + } + + if (lastIndex < markdown.length) { + const remainingText = markdown.substring(lastIndex); + if (remainingText.trim()) { + segments.push({ + id: segmentIdCounter++, + type: 'translatable', + content: remainingText, + }); + } else if (segments.length > 0) { + segments[segments.length - 1] = { + ...segments[segments.length - 1], + content: segments[segments.length - 1].content + remainingText, + }; + } + } + + return { segments }; +}; + +export interface TranslatableChunk { + content: string; + segmentIds: number[]; +} + +export const extractTranslatableChunks = ( + segments: MarkdownSegment[], + maxChunkSize: number = 5000 +): TranslatableChunk[] => { + const chunks: TranslatableChunk[] = []; + let currentContent = ''; + let currentIds: number[] = []; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + if (segment.type !== 'translatable') { + if (currentContent.trim() && currentIds.length > 0) { + chunks.push({ content: currentContent, segmentIds: [...currentIds] }); + currentContent = ''; + currentIds = []; + } + continue; + } + + if (currentContent.length === 0) { + currentContent = segment.content; + currentIds = [segment.id]; + } else if (currentContent.length + segment.content.length > maxChunkSize) { + chunks.push({ content: currentContent, segmentIds: [...currentIds] }); + currentContent = segment.content; + currentIds = [segment.id]; + } else { + currentContent += segment.content; + currentIds.push(segment.id); + } + } + + if (currentContent.trim() && currentIds.length > 0) { + chunks.push({ content: currentContent, segmentIds: currentIds }); + } + + return chunks; +}; + +export const reconstructMarkdown = ( + segments: MarkdownSegment[], + translatedContents: Map +): string => { + return segments + .map((segment) => { + if (segment.type === 'translatable') { + const translated = translatedContents.get(segment.id); + if (translated !== undefined) { + const leadingWs = segment.content.match(/^\s+/)?.[0] || ''; + const trailingWs = segment.content.match(/\s+$/)?.[0] || ''; + const trimmedTranslated = translated.replace(/^\s+/, '').replace(/\s+$/, ''); + return leadingWs + trimmedTranslated + trailingWs; + } + return segment.content; + } + return segment.content; + }) + .join(''); +}; + +export const mergeTranslatedChunks = ( + chunks: TranslatableChunk[], + translations: string[] +): Map => { + const map = new Map(); + + let translationIndex = 0; + for (const chunk of chunks) { + const translation = translations[translationIndex] ?? ''; + translationIndex++; + + const parts = splitTranslationToParts(translation, chunk.segmentIds.length); + + chunk.segmentIds.forEach((id, index) => { + map.set(id, parts[index] ?? ''); + }); + } + + return map; +}; + +const splitTranslationToParts = (translation: string, partCount: number): string[] => { + if (partCount <= 1) return [translation]; + + const parts: string[] = []; + + const paragraphs = translation.split(/\n\n+/); + + if (paragraphs.length >= partCount) { + const ratio = paragraphs.length / partCount; + for (let i = 0; i < partCount; i++) { + const start = Math.round(i * ratio); + const end = Math.round((i + 1) * ratio); + parts.push(paragraphs.slice(start, end).join('\n\n')); + } + } else { + const mid = Math.floor(paragraphs.length / 2); + parts.push(paragraphs.slice(0, mid).join('\n\n')); + parts.push(paragraphs.slice(mid).join('\n\n')); + + while (parts.length < partCount) { + parts.push(''); + } + } + + return parts.slice(0, partCount); +}; + +export const detectLanguage = (text: string): 'zh' | 'en' | 'unknown' => { + const chineseRegex = /[\u4e00-\u9fa5]/g; + const chineseMatches = text.match(chineseRegex); + const chineseCount = chineseMatches ? chineseMatches.length : 0; + + const englishRegex = /[a-zA-Z]/g; + const englishMatches = text.match(englishRegex); + const englishCount = englishMatches ? englishMatches.length : 0; + + const totalLetters = chineseCount + englishCount; + if (totalLetters === 0) return 'unknown'; + + const chineseRatio = chineseCount / totalLetters; + + if (chineseRatio > 0.3) return 'zh'; + if (chineseRatio < 0.1) return 'en'; + return 'unknown'; +}; + +export const getTranslateDirection = (sourceLang: 'zh' | 'en' | 'unknown', targetLang: 'zh' | 'en'): { from: string; to: string } => { + if (sourceLang === 'unknown') { + return { from: '', to: targetLang === 'zh' ? 'zh-Hans' : 'en' }; + } + + if (sourceLang === targetLang) { + return { from: sourceLang === 'zh' ? 'zh-Hans' : 'en', to: sourceLang === 'zh' ? 'en' : 'zh-Hans' }; + } + + return { + from: sourceLang === 'zh' ? 'zh-Hans' : 'en', + to: targetLang === 'zh' ? 'zh-Hans' : 'en', + }; +}; From be9f3c1afa6ce306a85086ad493774a41c5c4563 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:02:13 +0800 Subject: [PATCH 11/23] =?UTF-8?q?refactor(ReadmeModal):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84TOC=E6=8F=90=E5=8F=96=E9=80=BB=E8=BE=91=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BF=BB=E8=AF=91=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将TOC提取逻辑从README内容加载中分离,改为在翻译内容或原始内容更新时触发 优化翻译服务,支持请求中止并改进参数处理 增强markdown分割器,支持更多代码块格式并改进链接和图片处理 --- src/components/ReadmeModal.tsx | 17 +++++-- src/services/translateService.ts | 18 ++++--- src/utils/markdownSplitter.ts | 82 ++++++++++++++++++++++++++++---- 3 files changed, 99 insertions(+), 18 deletions(-) diff --git a/src/components/ReadmeModal.tsx b/src/components/ReadmeModal.tsx index 09354837..dcb341f5 100644 --- a/src/components/ReadmeModal.tsx +++ b/src/components/ReadmeModal.tsx @@ -265,9 +265,6 @@ export const ReadmeModal: React.FC = ({ if (content.trim()) { setReadmeContent(content); - const { items, idMap } = extractToc(content); - setTocItems(items); - setHeadingIdMap(idMap); } else { setError(language === 'zh' ? '该仓库没有 README 文件' : 'This repository has no README file'); } @@ -280,7 +277,7 @@ export const ReadmeModal: React.FC = ({ setLoading(false); } } - }, [repository, githubToken, language, extractToc]); + }, [repository, githubToken, language]); useEffect(() => { if (isOpen && repository) { @@ -288,6 +285,18 @@ export const ReadmeModal: React.FC = ({ } }, [isOpen, repository, fetchReadme]); + useEffect(() => { + if (translatedContent) { + const { items, idMap } = extractToc(translatedContent); + setTocItems(items); + setHeadingIdMap(idMap); + } else if (readmeContent) { + const { items, idMap } = extractToc(readmeContent); + setTocItems(items); + setHeadingIdMap(idMap); + } + }, [translatedContent, readmeContent, extractToc]); + useEffect(() => { setReadmeModalOpen(isOpen); return () => setReadmeModalOpen(false); diff --git a/src/services/translateService.ts b/src/services/translateService.ts index 461b151a..92c946e3 100644 --- a/src/services/translateService.ts +++ b/src/services/translateService.ts @@ -66,7 +66,7 @@ const storeToken = (token: string): void => { } }; -export const apiMsAuth = async (): Promise => { +export const apiMsAuth = async (signal?: AbortSignal): Promise => { const storedToken = getStoredToken(); if (storedToken) { cachedToken = storedToken; @@ -86,6 +86,7 @@ export const apiMsAuth = async (): Promise => { const response = await fetch(AUTH_URL, { method: 'GET', credentials: 'omit', + signal, }); if (!response.ok) { @@ -95,6 +96,11 @@ export const apiMsAuth = async (): Promise => { const token = await response.text(); storeToken(token); return token; + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + throw err; + } + throw err; } finally { tokenPromise = null; } @@ -117,10 +123,10 @@ export const translateText = async (options: TranslateOptions): Promise => { - const token = await apiMsAuth(); + const token = await apiMsAuth(signal); const params = queryString.stringify({ - from: from || '', + ...(from && { from }), to, 'api-version': '3.0', }); diff --git a/src/utils/markdownSplitter.ts b/src/utils/markdownSplitter.ts index 4cacf104..346c3dea 100644 --- a/src/utils/markdownSplitter.ts +++ b/src/utils/markdownSplitter.ts @@ -14,7 +14,7 @@ export const splitMarkdownForTranslation = (markdown: string): ParsedContent => segmentIdCounter = 0; const segments: MarkdownSegment[] = []; - const combinedRegex = /(```[\s\S]*?```)|(`[^`\n]+`)|(!\[[^\]]*\]\([^)]+\))|(\[[^\]]*\]\([^)]+\))/g; + const combinedRegex = /(```[\s\S]*?```)|(~~~[\s\S]*?~~~)|(`[^`\n]+`)|(!\[([^\]]*)\]\([^)]+\))|(\[([^\]]*)\]\([^)]+\))/g; let lastIndex = 0; let match: RegExpExecArray | null; @@ -47,21 +47,87 @@ export const splitMarkdownForTranslation = (markdown: string): ParsedContent => } else if (match[2]) { segments.push({ id: segmentIdCounter++, - type: 'inline_code', + type: 'code_block', content: contentWithPrefix, }); } else if (match[3]) { segments.push({ id: segmentIdCounter++, - type: 'image', + type: 'inline_code', content: contentWithPrefix, }); } else if (match[4]) { - segments.push({ - id: segmentIdCounter++, - type: 'link', - content: contentWithPrefix, - }); + const altText = match[5] || ''; + const imageMatch = match[4]; + const urlPart = imageMatch.substring(imageMatch.lastIndexOf('](')); + const prefix = contentWithPrefix.substring(0, contentWithPrefix.indexOf(imageMatch)); + + if (altText) { + if (prefix) { + segments.push({ + id: segmentIdCounter++, + type: 'translatable', + content: prefix, + }); + } + segments.push({ + id: segmentIdCounter++, + type: 'translatable', + content: '![', + }); + segments.push({ + id: segmentIdCounter++, + type: 'translatable', + content: altText, + }); + segments.push({ + id: segmentIdCounter++, + type: 'image', + content: urlPart, + }); + } else { + segments.push({ + id: segmentIdCounter++, + type: 'image', + content: contentWithPrefix, + }); + } + } else if (match[6]) { + const linkText = match[7] || ''; + const linkMatch = match[6]; + const urlPart = linkMatch.substring(linkMatch.lastIndexOf('](')); + const prefix = contentWithPrefix.substring(0, contentWithPrefix.indexOf(linkMatch)); + + if (linkText) { + if (prefix) { + segments.push({ + id: segmentIdCounter++, + type: 'translatable', + content: prefix, + }); + } + segments.push({ + id: segmentIdCounter++, + type: 'translatable', + content: '[', + }); + segments.push({ + id: segmentIdCounter++, + type: 'translatable', + content: linkText, + }); + segments.push({ + id: segmentIdCounter++, + type: 'link', + content: urlPart, + }); + } else { + segments.push({ + id: segmentIdCounter++, + type: 'link', + content: contentWithPrefix, + }); + } } lastIndex = match.index + fullMatch.length; From 3b7eb0573db999e885ff4f9e3a7788abd335c597 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Fri, 1 May 2026 22:09:32 +0800 Subject: [PATCH 12/23] =?UTF-8?q?feat(markdown):=20=E5=A2=9E=E5=BC=BA=20ma?= =?UTF-8?q?rkdown=20=E8=A7=A3=E6=9E=90=E5=99=A8=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=9B=B4=E5=A4=9A=E5=85=83=E7=B4=A0=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构 markdown 解析器,新增对 HTML 标签和带链接图片的支持 优化语言检测和翻译方向判断逻辑 调整 tailwind 配置中的颜色结构 --- src/utils/markdownSplitter.ts | 228 ++++++++++++++++++++++++++-------- tailwind.config.js | 10 +- 2 files changed, 179 insertions(+), 59 deletions(-) diff --git a/src/utils/markdownSplitter.ts b/src/utils/markdownSplitter.ts index 346c3dea..28251085 100644 --- a/src/utils/markdownSplitter.ts +++ b/src/utils/markdownSplitter.ts @@ -1,26 +1,32 @@ +// Markdown 分段接口定义 export interface MarkdownSegment { id: number; - type: 'translatable' | 'code_block' | 'inline_code' | 'link' | 'image'; + type: 'translatable' | 'code_block' | 'inline_code' | 'link' | 'image' | 'linked_image' | 'html_block'; content: string; } +// 解析结果接口 interface ParsedContent { segments: MarkdownSegment[]; } +// 全局分段 ID 计数器 let segmentIdCounter = 0; +// 将 Markdown 文本分割为可翻译段落 export const splitMarkdownForTranslation = (markdown: string): ParsedContent => { segmentIdCounter = 0; const segments: MarkdownSegment[] = []; - const combinedRegex = /(```[\s\S]*?```)|(~~~[\s\S]*?~~~)|(`[^`\n]+`)|(!\[([^\]]*)\]\([^)]+\))|(\[([^\]]*)\]\([^)]+\))/g; + // 组合正则表达式:匹配各类 Markdown 元素 + const combinedRegex = /()|(<(?:source|img|video|audio|iframe|object|embed|svg|canvas)[^>]*\/?>)|(```[\s\S]*?```)|(~~~[\s\S]*?~~~)|(`[^`\n]+`)|(\[!\[[^\]]*\]\([^)]*\)\]\([^)]+\))|(!\[([^\]]*)\]\([^)]+\))|(\[([^\]]*)\]\([^)]+\))/g; let lastIndex = 0; let match: RegExpExecArray | null; let pendingPrefix = ''; while ((match = combinedRegex.exec(markdown)) !== null) { + // 处理匹配项之间的普通文本 if (match.index > lastIndex) { const textBetween = markdown.substring(lastIndex, match.index); if (textBetween.trim()) { @@ -38,31 +44,94 @@ export const splitMarkdownForTranslation = (markdown: string): ParsedContent => const contentWithPrefix = pendingPrefix + fullMatch; pendingPrefix = ''; + // 处理 picture 标签 if (match[1]) { + const pictureMatch = match[1]; + const prefix = contentWithPrefix.substring(0, contentWithPrefix.indexOf(pictureMatch)); + + if (prefix) { + segments.push({ + id: segmentIdCounter++, + type: 'translatable', + content: prefix, + }); + } + + segments.push({ + id: segmentIdCounter++, + type: 'html_block', + content: pictureMatch, + }); + } else if (match[2]) { + // 处理其他 HTML 标签 + const htmlTagMatch = match[2]; + const prefix = contentWithPrefix.substring(0, contentWithPrefix.indexOf(htmlTagMatch)); + + if (prefix) { + segments.push({ + id: segmentIdCounter++, + type: 'translatable', + content: prefix, + }); + } + + segments.push({ + id: segmentIdCounter++, + type: 'html_block', + content: htmlTagMatch, + }); + } else if (match[3]) { + // 处理 ``` 代码块 segments.push({ id: segmentIdCounter++, type: 'code_block', content: contentWithPrefix, }); - } else if (match[2]) { + } else if (match[4]) { + // 处理 ~~~ 代码块 segments.push({ id: segmentIdCounter++, type: 'code_block', content: contentWithPrefix, }); - } else if (match[3]) { + } else if (match[5]) { + // 处理行内代码 segments.push({ id: segmentIdCounter++, type: 'inline_code', content: contentWithPrefix, }); - } else if (match[4]) { - const altText = match[5] || ''; - const imageMatch = match[4]; - const urlPart = imageMatch.substring(imageMatch.lastIndexOf('](')); - const prefix = contentWithPrefix.substring(0, contentWithPrefix.indexOf(imageMatch)); - + } else if (match[6]) { + // 处理带链接的图片 + const linkedImageMatch = match[6]; + const prefix = contentWithPrefix.substring(0, contentWithPrefix.indexOf(linkedImageMatch)); + + if (prefix) { + segments.push({ + id: segmentIdCounter++, + type: 'translatable', + content: prefix, + }); + } + // 将带链接的图片作为独立分段添加 + segments.push({ + id: segmentIdCounter++, + type: 'linked_image', + content: linkedImageMatch, + }); + } else if (match[7]) { + // 处理普通图片匹配(![alt](url)) + const altText = match[8] || ''; // 提取图片替代文本 + const imageMatch = match[7]; // 完整的图片匹配字符串 + // 计算 URL 部分的起始和结束位置 + const urlStartIndex = imageMatch.lastIndexOf('](') + 2; + const urlEndIndex = imageMatch.length - 1; + const urlPart = `(${imageMatch.substring(urlStartIndex, urlEndIndex)})`; // 提取 URL 部分 + const prefix = contentWithPrefix.substring(0, contentWithPrefix.indexOf(imageMatch)); // 前缀文本 + + // 如果有替代文本,将图片拆分为可翻译部分和不可翻译的 URL 部分 if (altText) { + // 添加前缀文本作为可翻译分段 if (prefix) { segments.push({ id: segmentIdCounter++, @@ -70,35 +139,45 @@ export const splitMarkdownForTranslation = (markdown: string): ParsedContent => content: prefix, }); } + // 添加图片标记开始符号 segments.push({ id: segmentIdCounter++, type: 'translatable', content: '![', }); + // 添加替代文本作为可翻译分段 segments.push({ id: segmentIdCounter++, type: 'translatable', - content: altText, + content: `${altText}]`, }); + // 添加 URL 作为图片类型分段(不可翻译) segments.push({ id: segmentIdCounter++, type: 'image', content: urlPart, }); } else { + // 无替代文本时,将整个图片作为不可翻译分段 segments.push({ id: segmentIdCounter++, type: 'image', content: contentWithPrefix, }); } - } else if (match[6]) { - const linkText = match[7] || ''; - const linkMatch = match[6]; - const urlPart = linkMatch.substring(linkMatch.lastIndexOf('](')); - const prefix = contentWithPrefix.substring(0, contentWithPrefix.indexOf(linkMatch)); - + } else if (match[9]) { + // 处理普通链接匹配([text](url)) + const linkText = match[10] || ''; // 提取链接文本 + const linkMatch = match[9]; // 完整的链接匹配字符串 + // 计算 URL 部分的起始和结束位置 + const urlStartIndex = linkMatch.lastIndexOf('](') + 2; + const urlEndIndex = linkMatch.length - 1; + const urlPart = `(${linkMatch.substring(urlStartIndex, urlEndIndex)})`; // 提取 URL 部分 + const prefix = contentWithPrefix.substring(0, contentWithPrefix.indexOf(linkMatch)); // 前缀文本 + + // 如果有链接文本,将链接拆分为可翻译部分和不可翻译的 URL 部分 if (linkText) { + // 添加前缀文本作为可翻译分段 if (prefix) { segments.push({ id: segmentIdCounter++, @@ -106,22 +185,20 @@ export const splitMarkdownForTranslation = (markdown: string): ParsedContent => content: prefix, }); } + // 添加链接文本作为可翻译分段 segments.push({ id: segmentIdCounter++, type: 'translatable', - content: '[', - }); - segments.push({ - id: segmentIdCounter++, - type: 'translatable', - content: linkText, + content: `[${linkText}]`, }); + // 添加 URL 作为链接类型分段(不可翻译) segments.push({ id: segmentIdCounter++, type: 'link', content: urlPart, }); } else { + // 无链接文本时,将整个链接作为不可翻译分段 segments.push({ id: segmentIdCounter++, type: 'link', @@ -130,11 +207,13 @@ export const splitMarkdownForTranslation = (markdown: string): ParsedContent => } } + // 更新最后处理的位置索引 lastIndex = match.index + fullMatch.length; } - + // 处理剩余的文本内容 if (lastIndex < markdown.length) { const remainingText = markdown.substring(lastIndex); + // 如果剩余文本非空,添加为可翻译分段 if (remainingText.trim()) { segments.push({ id: segmentIdCounter++, @@ -142,6 +221,7 @@ export const splitMarkdownForTranslation = (markdown: string): ParsedContent => content: remainingText, }); } else if (segments.length > 0) { + // 如果剩余文本只有空白字符,追加到最后一个分段 segments[segments.length - 1] = { ...segments[segments.length - 1], content: segments[segments.length - 1].content + remainingText, @@ -149,26 +229,37 @@ export const splitMarkdownForTranslation = (markdown: string): ParsedContent => } } + // 返回解析结果 return { segments }; }; +// 可翻译块接口定义 export interface TranslatableChunk { - content: string; - segmentIds: number[]; + content: string; // 块内容 + segmentIds: number[]; // 包含的分段 ID 列表 } +/** + * 从 Markdown 分段中提取可翻译的块 + * @param segments - Markdown 分段数组 + * @param maxChunkSize - 最大块大小(字符数),默认 5000 + * @returns 可翻译块数组 + */ export const extractTranslatableChunks = ( segments: MarkdownSegment[], maxChunkSize: number = 5000 ): TranslatableChunk[] => { - const chunks: TranslatableChunk[] = []; - let currentContent = ''; - let currentIds: number[] = []; + const chunks: TranslatableChunk[] = []; // 存储生成的块 + let currentContent = ''; // 当前块的内容 + let currentIds: number[] = []; // 当前块包含的分段 ID + // 遍历所有分段 for (let i = 0; i < segments.length; i++) { const segment = segments[i]; + // 跳过不可翻译的分段(代码块、图片等) if (segment.type !== 'translatable') { + // 如果当前块有内容,先保存当前块 if (currentContent.trim() && currentIds.length > 0) { chunks.push({ content: currentContent, segmentIds: [...currentIds] }); currentContent = ''; @@ -177,19 +268,24 @@ export const extractTranslatableChunks = ( continue; } + // 处理可翻译分段 if (currentContent.length === 0) { + // 当前块为空,直接添加 currentContent = segment.content; currentIds = [segment.id]; } else if (currentContent.length + segment.content.length > maxChunkSize) { + // 超出最大块大小,保存当前块并开始新块 chunks.push({ content: currentContent, segmentIds: [...currentIds] }); currentContent = segment.content; currentIds = [segment.id]; } else { + // 添加到当前块 currentContent += segment.content; currentIds.push(segment.id); } } + // 保存最后一个块(如果有内容) if (currentContent.trim() && currentIds.length > 0) { chunks.push({ content: currentContent, segmentIds: currentIds }); } @@ -197,40 +293,61 @@ export const extractTranslatableChunks = ( return chunks; }; +/** + * 根据翻译内容重建 Markdown 文本 + * @param segments - 原始 Markdown 分段 + * @param translatedContents - 翻译内容映射(分段 ID -> 翻译文本) + * @returns 重建后的 Markdown 文本 + */ export const reconstructMarkdown = ( segments: MarkdownSegment[], translatedContents: Map ): string => { return segments .map((segment) => { + // 只处理可翻译分段 if (segment.type === 'translatable') { const translated = translatedContents.get(segment.id); if (translated !== undefined) { + // 保留原始文本的首尾空白字符 const leadingWs = segment.content.match(/^\s+/)?.[0] || ''; const trailingWs = segment.content.match(/\s+$/)?.[0] || ''; + // 去除翻译文本的首尾空白字符 const trimmedTranslated = translated.replace(/^\s+/, '').replace(/\s+$/, ''); + // 组合保留的空白字符和翻译内容 return leadingWs + trimmedTranslated + trailingWs; } + // 无翻译时使用原始内容 return segment.content; } + // 非可翻译分段直接使用原始内容 return segment.content; }) .join(''); }; +/** + * 合并翻译后的块到分段映射 + * @param chunks - 可翻译块数组 + * @param translations - 翻译结果数组 + * @returns 分段 ID 到翻译内容的映射 + */ export const mergeTranslatedChunks = ( chunks: TranslatableChunk[], translations: string[] ): Map => { - const map = new Map(); + const map = new Map(); // 结果映射 let translationIndex = 0; + // 遍历每个块及其对应的翻译 for (const chunk of chunks) { - const translation = translations[translationIndex] ?? ''; + const translation = translations[translationIndex] ?? ''; // 获取对应翻译,默认为空字符串 translationIndex++; + // 将翻译拆分为与分段数量相等的部分 const parts = splitTranslationToParts(translation, chunk.segmentIds.length); + // 将各部分映射到对应的分段 ID chunk.segmentIds.forEach((id, index) => { map.set(id, parts[index] ?? ''); }); @@ -239,6 +356,12 @@ export const mergeTranslatedChunks = ( return map; }; +/** + * 将翻译文本拆分为指定数量的部分 + * @param translation - 翻译文本 + * @param partCount - 需要的部分数量 + * @returns 分拆后的翻译文本数组,每个元素为一个部分 + */ const splitTranslationToParts = (translation: string, partCount: number): string[] => { if (partCount <= 1) return [translation]; @@ -266,36 +389,31 @@ const splitTranslationToParts = (translation: string, partCount: number): string return parts.slice(0, partCount); }; -export const detectLanguage = (text: string): 'zh' | 'en' | 'unknown' => { - const chineseRegex = /[\u4e00-\u9fa5]/g; - const chineseMatches = text.match(chineseRegex); - const chineseCount = chineseMatches ? chineseMatches.length : 0; +export const detectLanguage = (content: string): 'zh' | 'en' | 'unknown' => { + const chineseCharRegex = /[\u4e00-\u9fa5]/; + const chineseMatch = content.match(chineseCharRegex); - const englishRegex = /[a-zA-Z]/g; - const englishMatches = text.match(englishRegex); - const englishCount = englishMatches ? englishMatches.length : 0; + if (chineseMatch && chineseMatch.length > 0) { + return 'zh'; + } - const totalLetters = chineseCount + englishCount; - if (totalLetters === 0) return 'unknown'; + const englishRegex = /[a-zA-Z]/; + const englishMatch = content.match(englishRegex); - const chineseRatio = chineseCount / totalLetters; + if (englishMatch && englishMatch.length > 0) { + return 'en'; + } - if (chineseRatio > 0.3) return 'zh'; - if (chineseRatio < 0.1) return 'en'; return 'unknown'; }; -export const getTranslateDirection = (sourceLang: 'zh' | 'en' | 'unknown', targetLang: 'zh' | 'en'): { from: string; to: string } => { - if (sourceLang === 'unknown') { - return { from: '', to: targetLang === 'zh' ? 'zh-Hans' : 'en' }; - } - - if (sourceLang === targetLang) { - return { from: sourceLang === 'zh' ? 'zh-Hans' : 'en', to: sourceLang === 'zh' ? 'en' : 'zh-Hans' }; - } - +export const getTranslateDirection = ( + detected: 'zh' | 'en' | 'unknown', + target: 'zh' | 'en' +): { from: string; to: string } => { + const sourceLang = detected === 'unknown' ? 'auto' : detected; return { - from: sourceLang === 'zh' ? 'zh-Hans' : 'en', - to: targetLang === 'zh' ? 'zh-Hans' : 'en', + from: sourceLang, + to: target, }; -}; +}; \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index a5345b26..a6759d82 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -31,10 +31,12 @@ export default { 'text-quaternary': '#62666d', // Linear Brand & Accent - 'brand-indigo': '#5e6ad2', - 'brand-violet': '#7170ff', - 'brand-hover': '#828fff', - 'security-lavender': '#7a7fad', + brand: { + indigo: '#5e6ad2', + violet: '#7170ff', + hover: '#828fff', + }, + 'security-lavender': '#7a7fad', // Linear Status 'status-green': '#27a644', From 3f640337556afd23f38da174ea0e27d469a27d86 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Sun, 3 May 2026 22:23:18 +0800 Subject: [PATCH 13/23] =?UTF-8?q?feat(=E7=BF=BB=E8=AF=91):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=8F=8C=E8=AF=ADMarkdown=E6=B8=B2=E6=9F=93=E5=99=A8?= =?UTF-8?q?=E5=B9=B6=E9=87=8D=E6=9E=84=E7=BF=BB=E8=AF=91=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构翻译服务,添加FALLBACK_TOKEN_TTL_MS常量用于令牌过期处理 新增BilingualMarkdownRenderer组件实现双语对照显示 重构useMarkdownTranslation钩子和markdownSplitter工具函数 优化翻译分段处理逻辑和占位符替换机制 --- src/components/BilingualMarkdownRenderer.tsx | 99 ++++ src/components/ReadmeModal.tsx | 71 +-- src/hooks/useMarkdownTranslation.ts | 151 +++--- src/services/translateService.ts | 10 +- src/utils/markdownSplitter.ts | 492 +++++-------------- 5 files changed, 348 insertions(+), 475 deletions(-) create mode 100644 src/components/BilingualMarkdownRenderer.tsx diff --git a/src/components/BilingualMarkdownRenderer.tsx b/src/components/BilingualMarkdownRenderer.tsx new file mode 100644 index 00000000..f1a02564 --- /dev/null +++ b/src/components/BilingualMarkdownRenderer.tsx @@ -0,0 +1,99 @@ +import React, { memo, useMemo } from 'react'; +import MarkdownRenderer from './MarkdownRenderer'; +import { TranslationSegment } from '../utils/markdownSplitter'; +import { Loader2 } from 'lucide-react'; + +interface BilingualMarkdownRendererProps { + segments: TranslationSegment[]; + baseUrl?: string; + headingIds?: Map; + fontSize?: 'small' | 'medium' | 'large'; + showTranslation?: boolean; + language?: 'zh' | 'en'; +} + +const SegmentBlock: React.FC<{ + segment: TranslationSegment; + baseUrl?: string; + headingIds?: Map; + fontSize?: 'small' | 'medium' | 'large'; + showTranslation: boolean; + language: 'zh' | 'en'; +}> = memo(({ segment, baseUrl, headingIds, fontSize, showTranslation, language }) => { + const isTranslating = segment.status === 'translating'; + const hasTranslation = segment.translatedContent !== null && segment.status === 'done'; + + return ( +
+
+ +
+ + {showTranslation && ( + <> + {isTranslating && ( +
+
+ + {language === 'zh' ? '翻译中...' : 'Translating...'} +
+
+ )} + + {hasTranslation && segment.translatedContent && ( +
+ +
+ )} + + )} +
+ ); +}); + +SegmentBlock.displayName = 'SegmentBlock'; + +const BilingualMarkdownRenderer: React.FC = memo(({ + segments, + baseUrl, + headingIds, + fontSize = 'medium', + showTranslation = true, + language = 'zh', +}) => { + const renderedSegments = useMemo(() => { + return segments.map((segment) => ( + + )); + }, [segments, baseUrl, headingIds, fontSize, showTranslation, language]); + + return ( +
+ {renderedSegments} +
+ ); +}); + +BilingualMarkdownRenderer.displayName = 'BilingualMarkdownRenderer'; + +export default BilingualMarkdownRenderer; diff --git a/src/components/ReadmeModal.tsx b/src/components/ReadmeModal.tsx index dcb341f5..fed3e7d3 100644 --- a/src/components/ReadmeModal.tsx +++ b/src/components/ReadmeModal.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; import { X, Loader2, AlertCircle, FileText, ExternalLink, List, Type, ArrowUp, Languages, RotateCcw } from 'lucide-react'; import MarkdownRenderer from './MarkdownRenderer'; +import BilingualMarkdownRenderer from './BilingualMarkdownRenderer'; import { stripMarkdownFormatting } from '../utils/markdownUtils'; import { Repository } from '../types'; import { GitHubApiService } from '../services/githubApi'; @@ -54,14 +55,23 @@ export const ReadmeModal: React.FC = ({ status: translateStatus, progress: translateProgress, error: translateError, - translatedContent, - translate, + segments, +translate, revert, reset: resetTranslation, } = useMarkdownTranslation({ targetLanguage: language, }); + const getDisplayContent = useCallback((): string => { + if (translateStatus === 'translated' && segments.length > 0) { + return segments + .map(s => s.translatedContent || s.originalContent) + .join('\n\n'); + } + return readmeContent; + }, [translateStatus, segments, readmeContent]); + const currentFontSize = FONT_SIZES[fontSizeIndex].value; const getFontSizeType = useCallback((): 'small' | 'medium' | 'large' => { @@ -232,8 +242,6 @@ export const ReadmeModal: React.FC = ({ revert(); }, [revert]); - const displayContent = translatedContent || readmeContent; - const fetchReadme = useCallback(async () => { if (!repository) return; @@ -286,16 +294,13 @@ export const ReadmeModal: React.FC = ({ }, [isOpen, repository, fetchReadme]); useEffect(() => { - if (translatedContent) { - const { items, idMap } = extractToc(translatedContent); - setTocItems(items); - setHeadingIdMap(idMap); - } else if (readmeContent) { - const { items, idMap } = extractToc(readmeContent); + const content = getDisplayContent(); + if (content) { + const { items, idMap } = extractToc(content); setTocItems(items); setHeadingIdMap(idMap); } - }, [translatedContent, readmeContent, extractToc]); + }, [readmeContent, translateStatus, segments, extractToc, getDisplayContent]); useEffect(() => { setReadmeModalOpen(isOpen); @@ -382,6 +387,9 @@ export const ReadmeModal: React.FC = ({ return 'text-gray-500 dark:text-gray-500 text-xs'; }; + const isTranslating = translateStatus === 'translating'; + const isTranslated = translateStatus === 'translated'; + return (
= ({ style={{ maxWidth: '1130px' }} onClick={(e) => e.stopPropagation()} > - {/* Reading Progress Bar */} {readmeContent && !loading && (
= ({
)} - {/* Header */}
= ({
{readmeContent && !loading && ( - translateStatus === 'translated' ? ( + isTranslated ? (
- {/* Main Content Area */}
- {/* TOC Sidebar */} {showToc && tocItems.length > 0 && (

@@ -537,7 +541,6 @@ export const ReadmeModal: React.FC = ({

)} - {/* Content */}
= ({ {language === 'zh' ? '重试' : 'Retry'}
- ) : displayContent ? ( - + ) : readmeContent ? ( + isTranslated ? ( + + ) : ( + + ) ) : (
@@ -581,7 +595,6 @@ export const ReadmeModal: React.FC = ({ )}
- {/* Back to Top Button */} {showBackToTop && (
- {showTranslation && ( - <> - {isTranslating && ( -
-
- - {language === 'zh' ? '翻译中...' : 'Translating...'} -
-
- )} - - {hasTranslation && segment.translatedContent && ( -
- -
- )} - + {showTranslation && hasTranslation && segment.translatedContent && ( +
+ +
)}
); diff --git a/src/hooks/useMarkdownTranslation.ts b/src/hooks/useMarkdownTranslation.ts index e3a89c4a..80b1eabc 100644 --- a/src/hooks/useMarkdownTranslation.ts +++ b/src/hooks/useMarkdownTranslation.ts @@ -92,10 +92,7 @@ export const useMarkdownTranslation = ( for (let j = i; j < Math.min(i + batchSize, totalSegments); j++) { const segment = newSegments[j]; - if (segment.hasCodeBlock) { - batchTexts.push(segment.originalContent); - batchIndices.push(j); - } else if (segment.originalContent.trim()) { + if (segment.originalContent.trim()) { batchTexts.push(segment.originalContent); batchIndices.push(j); } @@ -138,7 +135,7 @@ export const useMarkdownTranslation = ( onProgress?.(totalSegments, totalSegments); return true; } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { + if ((err as { name?: string })?.name === 'AbortError') { setStatus('idle'); return false; } diff --git a/src/services/translateService.ts b/src/services/translateService.ts index b652e168..24c00e8d 100644 --- a/src/services/translateService.ts +++ b/src/services/translateService.ts @@ -75,7 +75,7 @@ export const apiMsAuth = async (signal?: AbortSignal): Promise => { return storedToken.token; } - if (isTokenValid(cachedToken)) { + if (cachedToken && isTokenValid(cachedToken)) { return cachedToken.token; } @@ -99,9 +99,6 @@ export const apiMsAuth = async (signal?: AbortSignal): Promise => { storeToken(token); return token; } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - throw err; - } throw err; } finally { tokenPromise = null; diff --git a/src/utils/markdownSplitter.ts b/src/utils/markdownSplitter.ts index 8f3f1b04..2dbb5e84 100644 --- a/src/utils/markdownSplitter.ts +++ b/src/utils/markdownSplitter.ts @@ -27,7 +27,7 @@ export const splitMarkdownSimple = (markdown: string): SplitResult => { let processed = markdown; - processed = processed.replace(/```[\s\S]*?```/g, (match) => { + processed = processed.replace(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g, (match) => { return createPlaceholder('CODE', match); }); @@ -117,10 +117,12 @@ export const detectLanguage = (content: string): 'zh' | 'en' | 'unknown' => { export const getTranslateDirection = ( detected: 'zh' | 'en' | 'unknown', target: 'zh' | 'en' -): { from: string; to: string } => { - const sourceLang = detected === 'unknown' ? 'auto' : detected; +): { from?: string; to: string } => { + if (detected === 'unknown') { + return { to: target }; + } return { - from: sourceLang, + from: detected, to: target, }; }; From fac94e3fb00e00b5f5cf053e97e3dd2d0bc5eaf2 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Sun, 3 May 2026 23:00:32 +0800 Subject: [PATCH 15/23] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=AE=88=E5=8D=AB=E5=B9=B6=E7=A7=BB=E9=99=A4=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=9A=84=E8=AF=AD=E8=A8=80=E5=B1=9E=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 优化 translateService 中的 isTokenValid 类型守卫,使其更准确地判断 token 有效性 移除 BilingualMarkdownRenderer 中未使用的 language 属性及相关依赖 --- src/components/BilingualMarkdownRenderer.tsx | 4 +--- src/services/translateService.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/BilingualMarkdownRenderer.tsx b/src/components/BilingualMarkdownRenderer.tsx index 93f38e3c..dc953bfc 100644 --- a/src/components/BilingualMarkdownRenderer.tsx +++ b/src/components/BilingualMarkdownRenderer.tsx @@ -17,7 +17,6 @@ const SegmentBlock: React.FC<{ headingIds?: Map; fontSize?: 'small' | 'medium' | 'large'; showTranslation: boolean; - language: 'zh' | 'en'; }> = memo(({ segment, baseUrl, headingIds, fontSize, showTranslation }) => { const hasTranslation = segment.translatedContent !== null && segment.status === 'done'; @@ -67,10 +66,9 @@ const BilingualMarkdownRenderer: React.FC = memo headingIds={headingIds} fontSize={fontSize} showTranslation={showTranslation} - language={language} /> )); - }, [segments, baseUrl, headingIds, fontSize, showTranslation, language]); + }, [segments, baseUrl, headingIds, fontSize, showTranslation]); return (
diff --git a/src/services/translateService.ts b/src/services/translateService.ts index 24c00e8d..d673f3d8 100644 --- a/src/services/translateService.ts +++ b/src/services/translateService.ts @@ -34,7 +34,7 @@ const parseJwtExpiration = (token: string): number => { } }; -const isTokenValid = (cached: CachedToken | null): boolean => { +const isTokenValid = (cached: CachedToken | null): cached is CachedToken => { if (!cached) return false; return Date.now() < cached.expiresAt - TOKEN_REFRESH_BUFFER_MS; }; @@ -75,7 +75,7 @@ export const apiMsAuth = async (signal?: AbortSignal): Promise => { return storedToken.token; } - if (cachedToken && isTokenValid(cachedToken)) { + if (isTokenValid(cachedToken)) { return cachedToken.token; } From f722aab43207710513b2897659cb54cede90893b Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Sun, 3 May 2026 23:45:59 +0800 Subject: [PATCH 16/23] =?UTF-8?q?feat(markdown):=20=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=E5=8A=9F=E8=83=BD=E5=B9=B6=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=A8=A1=E5=BC=8F=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持内联代码和details标签的占位处理,保留原始分隔符 添加原文/译文/双语三种显示模式切换功能 优化翻译按钮样式和占位符恢复逻辑 --- src/components/BilingualMarkdownRenderer.tsx | 150 +++++++++++++++++-- src/components/ReadmeModal.tsx | 25 ++-- src/utils/markdownSplitter.ts | 53 +++++-- 3 files changed, 194 insertions(+), 34 deletions(-) diff --git a/src/components/BilingualMarkdownRenderer.tsx b/src/components/BilingualMarkdownRenderer.tsx index dc953bfc..0eeac843 100644 --- a/src/components/BilingualMarkdownRenderer.tsx +++ b/src/components/BilingualMarkdownRenderer.tsx @@ -1,30 +1,112 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo, useMemo, useState } from 'react'; import MarkdownRenderer from './MarkdownRenderer'; import { TranslationSegment } from '../utils/markdownSplitter'; +import { FileText, Languages, Eye, EyeOff } from 'lucide-react'; + +export type DisplayMode = 'original' | 'translated' | 'bilingual'; interface BilingualMarkdownRendererProps { segments: TranslationSegment[]; + placeholderMap: Map; baseUrl?: string; headingIds?: Map; fontSize?: 'small' | 'medium' | 'large'; - showTranslation?: boolean; language?: 'zh' | 'en'; + defaultDisplayMode?: DisplayMode; + onDisplayModeChange?: (mode: DisplayMode) => void; } const SegmentBlock: React.FC<{ segment: TranslationSegment; + placeholderMap: Map; baseUrl?: string; headingIds?: Map; fontSize?: 'small' | 'medium' | 'large'; - showTranslation: boolean; -}> = memo(({ segment, baseUrl, headingIds, fontSize, showTranslation }) => { + displayMode: DisplayMode; +}> = memo(({ segment, placeholderMap, baseUrl, headingIds, fontSize, displayMode }) => { const hasTranslation = segment.translatedContent !== null && segment.status === 'done'; + const getDisplayContent = (content: string): string => { + if (!content || !placeholderMap.size) return content; + + let result = content; + + const entries = Array.from(placeholderMap.entries()); + + for (const [key, value] of entries) { + if (result.includes(key)) { + result = result.split(key).join(value); + continue; + } + + const coreId = key.replace(/^_+|_+$/g, ''); + const pattern = new RegExp(`_+${coreId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_+`, 'gi'); + + if (pattern.test(result)) { + result = result.replace(pattern, value); + } + } + + const placeholderPattern = /__[A-Z]+_\d+__/g; + result = result.replace(placeholderPattern, (match) => { + const coreId = match.replace(/^_+|_+$/g, ''); + const found = Array.from(placeholderMap.entries()).find(([k]) => k.replace(/^_+|_+$/g, '') === coreId); + if (found) { + return found[1]; + } + return match; + }); + + return result; + }; + + if (displayMode === 'original') { + return ( +
+ +
+ ); + } + + if (displayMode === 'translated') { + if (!hasTranslation || !segment.translatedContent) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); + } + return (
- {showTranslation && hasTranslation && segment.translatedContent && ( + {hasTranslation && segment.translatedContent && (
= memo(({ segments, + placeholderMap, baseUrl, headingIds, fontSize = 'medium', - showTranslation = true, language = 'zh', + defaultDisplayMode = 'bilingual', + onDisplayModeChange, }) => { + const [displayMode, setDisplayMode] = useState(defaultDisplayMode); + + const handleModeChange = (newMode: DisplayMode) => { + setDisplayMode(newMode); + onDisplayModeChange?.(newMode); + }; + const renderedSegments = useMemo(() => { return segments.map((segment) => ( )); - }, [segments, baseUrl, headingIds, fontSize, showTranslation]); + }, [segments, placeholderMap, baseUrl, headingIds, fontSize, displayMode]); + + const modeButtons = [ + { + mode: 'original' as DisplayMode, + icon: FileText, + label: language === 'zh' ? '原文' : 'Original', + active: displayMode === 'original' + }, + { + mode: 'translated' as DisplayMode, + icon: Languages, + label: language === 'zh' ? '译文' : 'Translated', + active: displayMode === 'translated' + }, + { + mode: 'bilingual' as DisplayMode, + icon: Eye, + label: language === 'zh' ? '双语' : 'Bilingual', + active: displayMode === 'bilingual' + }, + ]; return (
+
+ {modeButtons.map(({ mode, icon: Icon, label, active }) => ( + + ))} +
{renderedSegments}
); diff --git a/src/components/ReadmeModal.tsx b/src/components/ReadmeModal.tsx index fed3e7d3..f487bcd8 100644 --- a/src/components/ReadmeModal.tsx +++ b/src/components/ReadmeModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { X, Loader2, AlertCircle, FileText, ExternalLink, List, Type, ArrowUp, Languages, RotateCcw } from 'lucide-react'; +import { X, Loader2, AlertCircle, FileText, ExternalLink, List, Type, ArrowUp, Languages } from 'lucide-react'; import MarkdownRenderer from './MarkdownRenderer'; import BilingualMarkdownRenderer from './BilingualMarkdownRenderer'; import { stripMarkdownFormatting } from '../utils/markdownUtils'; @@ -56,7 +56,8 @@ export const ReadmeModal: React.FC = ({ progress: translateProgress, error: translateError, segments, -translate, + placeholderMap, + translate, revert, reset: resetTranslation, } = useMarkdownTranslation({ @@ -65,9 +66,13 @@ translate, const getDisplayContent = useCallback((): string => { if (translateStatus === 'translated' && segments.length > 0) { - return segments - .map(s => s.translatedContent || s.originalContent) - .join('\n\n'); + return segments.reduce((acc, segment, index) => { + const content = segment.translatedContent || segment.originalContent; + if (index === 0) { + return content; + } + return acc + segment.separator + content; + }, ''); } return readmeContent; }, [translateStatus, segments, readmeContent]); @@ -436,11 +441,11 @@ translate, isTranslated ? ( ) : ( + ); + })} +
+ )} + +
- - {hasTranslation && segment.translatedContent && ( -
- + + {status === 'translating' && ( +
+ + + {language === 'zh' ? '翻译中...' : 'Translating...'} + {progress.total > 0 && ` ${progress.current}/${progress.total}`} +
)} -
- ); -}); -SegmentBlock.displayName = 'SegmentBlock'; - -const BilingualMarkdownRenderer: React.FC = memo(({ - segments, - placeholderMap, - baseUrl, - headingIds, - fontSize = 'medium', - language = 'zh', - defaultDisplayMode = 'bilingual', - onDisplayModeChange, -}) => { - const [displayMode, setDisplayMode] = useState(defaultDisplayMode); - - const handleModeChange = (newMode: DisplayMode) => { - setDisplayMode(newMode); - onDisplayModeChange?.(newMode); - }; - - const renderedSegments = useMemo(() => { - return segments.map((segment) => ( - - )); - }, [segments, placeholderMap, baseUrl, headingIds, fontSize, displayMode]); - - const modeButtons = [ - { - mode: 'original' as DisplayMode, - icon: FileText, - label: language === 'zh' ? '原文' : 'Original', - active: displayMode === 'original' - }, - { - mode: 'translated' as DisplayMode, - icon: Languages, - label: language === 'zh' ? '译文' : 'Translated', - active: displayMode === 'translated' - }, - { - mode: 'bilingual' as DisplayMode, - icon: Eye, - label: language === 'zh' ? '双语' : 'Bilingual', - active: displayMode === 'bilingual' - }, - ]; - - return ( -
-
- {modeButtons.map(({ mode, icon: Icon, label, active }) => ( - - ))} -
- {renderedSegments} + {error && ( +
{error}
+ )}
); }); BilingualMarkdownRenderer.displayName = 'BilingualMarkdownRenderer'; -export default BilingualMarkdownRenderer; +export default memo(BilingualMarkdownRenderer); diff --git a/src/components/ReadmeModal.tsx b/src/components/ReadmeModal.tsx index f487bcd8..764a0376 100644 --- a/src/components/ReadmeModal.tsx +++ b/src/components/ReadmeModal.tsx @@ -1,13 +1,11 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { X, Loader2, AlertCircle, FileText, ExternalLink, List, Type, ArrowUp, Languages } from 'lucide-react'; -import MarkdownRenderer from './MarkdownRenderer'; -import BilingualMarkdownRenderer from './BilingualMarkdownRenderer'; +import { X, Loader2, AlertCircle, FileText, ExternalLink, List, Type, ArrowUp, Languages, Eye } from 'lucide-react'; +import BilingualMarkdownRenderer, { DisplayMode, BilingualMarkdownRendererHandle, TranslationStatus } from './BilingualMarkdownRenderer'; import { stripMarkdownFormatting } from '../utils/markdownUtils'; import { Repository } from '../types'; import { GitHubApiService } from '../services/githubApi'; import { backend } from '../services/backendAdapter'; import { useAppStore } from '../store/useAppStore'; -import { useMarkdownTranslation } from '../hooks/useMarkdownTranslation'; interface TocItem { id: string; @@ -45,37 +43,25 @@ export const ReadmeModal: React.FC = ({ const [scrollProgress, setScrollProgress] = useState(0); const [showBackToTop, setShowBackToTop] = useState(false); const [activeHeadingId, setActiveHeadingId] = useState(null); + const [displayMode, setDisplayMode] = useState('bilingual'); + const [errorExpanded, setErrorExpanded] = useState(false); + const [tocWidth, setTocWidth] = useState(224); + const [translatedHeadingMap, setTranslatedHeadingMap] = useState>(new Map()); const modalRef = useRef(null); const contentRef = useRef(null); const previousFocusRef = useRef(null); const abortControllerRef = useRef(null); + const isResizingRef = useRef(false); + const startXRef = useRef(0); + const startWidthRef = useRef(0); - const { - status: translateStatus, - progress: translateProgress, - error: translateError, - segments, - placeholderMap, - translate, - revert, - reset: resetTranslation, - } = useMarkdownTranslation({ - targetLanguage: language, - }); - - const getDisplayContent = useCallback((): string => { - if (translateStatus === 'translated' && segments.length > 0) { - return segments.reduce((acc, segment, index) => { - const content = segment.translatedContent || segment.originalContent; - if (index === 0) { - return content; - } - return acc + segment.separator + content; - }, ''); - } - return readmeContent; - }, [translateStatus, segments, readmeContent]); + const bilingualRef = useRef(null); + const [translateStatus, setTranslateStatus] = useState('idle'); + const [translateProgress, setTranslateProgress] = useState({ current: 0, total: 0 }); + const [translateError, setTranslateError] = useState(null); + + const displayContent = readmeContent; const currentFontSize = FONT_SIZES[fontSizeIndex].value; @@ -121,6 +107,19 @@ export const ReadmeModal: React.FC = ({ if (!contentRef.current) return; const container = contentRef.current; + const translationWrapper = container.querySelector(`[data-bi-heading-id="${CSS.escape(id)}"]`) as HTMLElement | null; + if (translationWrapper) { + const elementRect = translationWrapper.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const scrollTop = container.scrollTop + elementRect.top - containerRect.top - 20; + try { + container.scrollTo({ top: scrollTop, behavior: 'smooth' }); + } catch { + container.scrollTop = scrollTop; + } + return; + } + let element = container.querySelector(`#${CSS.escape(id)}`) as HTMLElement | null; if (!element && fallbackText) { @@ -220,6 +219,26 @@ export const ReadmeModal: React.FC = ({ }; }, [tocItems, readmeContent]); + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizingRef.current) return; + const delta = e.clientX - startXRef.current; + setTocWidth(Math.max(150, Math.min(500, startWidthRef.current + delta))); + }; + const handleMouseUp = () => { + if (!isResizingRef.current) return; + isResizingRef.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + const scrollToTop = useCallback(() => { if (contentRef.current) { try { @@ -234,18 +253,32 @@ export const ReadmeModal: React.FC = ({ setFontSizeIndex((prev) => (prev + 1) % FONT_SIZES.length); }, []); + const handleResizeMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isResizingRef.current = true; + startXRef.current = e.clientX; + startWidthRef.current = tocWidth; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, [tocWidth]); + const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); const handleTranslate = useCallback(async () => { if (translateStatus === 'translating') return; - if (readmeContent) { - await translate(readmeContent); - } - }, [readmeContent, translate, translateStatus]); + await bilingualRef.current?.translate(); + }, [translateStatus]); const handleRevertTranslation = useCallback(() => { - revert(); - }, [revert]); + bilingualRef.current?.revert(); + setTranslatedHeadingMap(new Map()); + }, []); + + const handleHeadingsTranslated = useCallback((headings: { id: string; text: string }[]) => { + const map = new Map(); + headings.forEach(h => map.set(h.id, h.text)); + setTranslatedHeadingMap(map); + }, []); const fetchReadme = useCallback(async () => { if (!repository) return; @@ -299,13 +332,12 @@ export const ReadmeModal: React.FC = ({ }, [isOpen, repository, fetchReadme]); useEffect(() => { - const content = getDisplayContent(); - if (content) { - const { items, idMap } = extractToc(content); + if (displayContent) { + const { items, idMap } = extractToc(displayContent); setTocItems(items); setHeadingIdMap(idMap); } - }, [readmeContent, translateStatus, segments, extractToc, getDisplayContent]); + }, [displayContent, extractToc]); useEffect(() => { setReadmeModalOpen(isOpen); @@ -326,11 +358,17 @@ export const ReadmeModal: React.FC = ({ setScrollProgress(0); setShowBackToTop(false); setActiveHeadingId(null); - resetTranslation(); + setDisplayMode('bilingual'); + setErrorExpanded(false); + bilingualRef.current?.revert(); + setTranslateStatus('idle'); + setTranslateProgress({ current: 0, total: 0 }); + setTranslateError(null); + setTranslatedHeadingMap(new Map()); } else { setShowToc(true); } - }, [isOpen, resetTranslation]); + }, [isOpen]); useEffect(() => { return () => { @@ -394,6 +432,7 @@ export const ReadmeModal: React.FC = ({ const isTranslating = translateStatus === 'translating'; const isTranslated = translateStatus === 'translated'; + const isTranslateError = translateStatus === 'error'; return (
@@ -439,14 +478,53 @@ export const ReadmeModal: React.FC = ({
{readmeContent && !loading && ( isTranslated ? ( - + <> + + {([ + { mode: 'original' as DisplayMode, icon: FileText, label: t('原文', 'Original') }, + { mode: 'translated' as DisplayMode, icon: Languages, label: t('译文', 'Translated') }, + { mode: 'bilingual' as DisplayMode, icon: Eye, label: t('双语', 'Bilingual') }, + ]).map(({ mode, icon: Icon, label }) => ( + + ))} + + ) : isTranslateError ? ( + <> + + + ) : ( ) )} {translateError && ( -
+
setErrorExpanded(!errorExpanded)} + title={!errorExpanded ? translateError : undefined} + > {translateError}
)} @@ -523,27 +605,41 @@ export const ReadmeModal: React.FC = ({
{showToc && tocItems.length > 0 && ( -
-

- {t('目录', 'Contents')} -

- -
+ <> +
+

+ {t('目录', 'Contents')} +

+ +
+
+
+
+ )}
= ({
) : readmeContent ? ( - isTranslated ? ( - - ) : ( - - ) + setTranslateProgress({ current, total })} + onHeadingsTranslated={handleHeadingsTranslated} + /> ) : (
diff --git a/src/hooks/useMarkdownTranslation.ts b/src/hooks/useMarkdownTranslation.ts deleted file mode 100644 index 80b1eabc..00000000 --- a/src/hooks/useMarkdownTranslation.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { useState, useCallback, useRef } from 'react'; -import { translateBatch, clearTranslateCache } from '../services/translateService'; -import { - splitMarkdownSimple, - restorePlaceholders, - detectLanguage, - getTranslateDirection, - TranslationSegment, - cleanTranslatedText, -} from '../utils/markdownSplitter'; - -export type TranslationStatus = 'idle' | 'translating' | 'translated' | 'error'; - -interface UseMarkdownTranslationOptions { - targetLanguage: 'zh' | 'en'; - onProgress?: (current: number, total: number) => void; - onSegmentTranslated?: (index: number, total: number) => void; -} - -interface UseMarkdownTranslationResult { - status: TranslationStatus; - progress: { current: number; total: number }; - error: string | null; - segments: TranslationSegment[]; - placeholderMap: Map; - detectedLanguage: 'zh' | 'en' | 'unknown'; - translate: (content: string) => Promise; - revert: () => void; - clearError: () => void; - reset: () => void; -} - -export const useMarkdownTranslation = ( - options: UseMarkdownTranslationOptions -): UseMarkdownTranslationResult => { - const { targetLanguage, onProgress, onSegmentTranslated } = options; - - const [status, setStatus] = useState('idle'); - const [progress, setProgress] = useState({ current: 0, total: 0 }); - const [error, setError] = useState(null); - const [segments, setSegments] = useState([]); - const [placeholderMap, setPlaceholderMap] = useState>(new Map()); - const [detectedLanguage, setDetectedLanguage] = useState<'zh' | 'en' | 'unknown'>('unknown'); - - const abortControllerRef = useRef(null); - - const translate = useCallback( - async (content: string): Promise => { - if (status === 'translating') { - return false; - } - - const { segments: newSegments, placeholderMap: newPlaceholderMap } = splitMarkdownSimple(content); - - if (newSegments.length === 0) { - setStatus('translated'); - setSegments([]); - setPlaceholderMap(new Map()); - return true; - } - - const detected = detectLanguage(content); - setDetectedLanguage(detected); - - if (detected === targetLanguage) { - setError(detected === 'zh' ? '内容已是中文' : 'Content is already in English'); - setStatus('error'); - return false; - } - - setStatus('translating'); - setError(null); - setSegments(newSegments); - setPlaceholderMap(newPlaceholderMap); - setProgress({ current: 0, total: newSegments.length }); - - abortControllerRef.current = new AbortController(); - - try { - const direction = getTranslateDirection(detected, targetLanguage); - const totalSegments = newSegments.length; - let completedCount = 0; - - const batchSize = 10; - for (let i = 0; i < totalSegments; i += batchSize) { - if (abortControllerRef.current.signal.aborted) { - throw new DOMException('Aborted', 'AbortError'); - } - - const batchIndices: number[] = []; - const batchTexts: string[] = []; - - for (let j = i; j < Math.min(i + batchSize, totalSegments); j++) { - const segment = newSegments[j]; - if (segment.originalContent.trim()) { - batchTexts.push(segment.originalContent); - batchIndices.push(j); - } - } - - if (batchTexts.length === 0) continue; - - const results = await translateBatch( - batchTexts, - direction.to, - direction.from, - abortControllerRef.current.signal - ); - - setSegments(prev => { - const updated = [...prev]; - batchIndices.forEach((segIndex, resultIndex) => { - let translatedText = results[resultIndex]?.translatedText || ''; - translatedText = restorePlaceholders(translatedText, newPlaceholderMap); - translatedText = cleanTranslatedText(translatedText); - updated[segIndex] = { - ...updated[segIndex], - translatedContent: translatedText, - status: 'done', - }; - }); - return updated; - }); - - completedCount += batchIndices.length; - setProgress({ current: completedCount, total: totalSegments }); - onProgress?.(completedCount, totalSegments); - batchIndices.forEach((segIndex) => { - onSegmentTranslated?.(segIndex, totalSegments); - }); - } - - setStatus('translated'); - setProgress({ current: totalSegments, total: totalSegments }); - onProgress?.(totalSegments, totalSegments); - return true; - } catch (err) { - if ((err as { name?: string })?.name === 'AbortError') { - setStatus('idle'); - return false; - } - - const errorMessage = - err instanceof Error ? err.message : 'Translation failed'; - setError(errorMessage); - setStatus('error'); - return false; - } - }, - [status, targetLanguage, onProgress, onSegmentTranslated] - ); - - const revert = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - setStatus('idle'); - setSegments([]); - setPlaceholderMap(new Map()); - setProgress({ current: 0, total: 0 }); - setError(null); - }, []); - - const clearError = useCallback(() => { - setError(null); - if (status === 'error') { - setStatus('idle'); - } - }, [status]); - - const reset = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - setStatus('idle'); - setSegments([]); - setPlaceholderMap(new Map()); - setProgress({ current: 0, total: 0 }); - setError(null); - setDetectedLanguage('unknown'); - }, []); - - return { - status, - progress, - error, - segments, - placeholderMap, - detectedLanguage, - translate, - revert, - clearError, - reset, - }; -}; - -export const clearTranslationTokenCache = (): void => { - clearTranslateCache(); -}; diff --git a/src/services/translateService.ts b/src/services/translateService.ts index d673f3d8..11f3b572 100644 --- a/src/services/translateService.ts +++ b/src/services/translateService.ts @@ -68,6 +68,38 @@ const storeToken = (token: string): void => { } }; +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +const withTranslateRetry = async ( + operation: (token: string) => Promise, + signal?: AbortSignal, + maxRetries = 3, + baseDelay = 1000 +): Promise => { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const token = await apiMsAuth(signal); + return await operation(token); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + + const name = (err as { name?: string })?.name; + if (name === 'AbortError' || name === 'CanceledError') { + throw err; + } + + if (attempt < maxRetries) { + await sleep(baseDelay * Math.pow(2, attempt - 1)); + } + } + } + + throw lastError!; +}; + export const apiMsAuth = async (signal?: AbortSignal): Promise => { const storedToken = getStoredToken(); if (storedToken) { @@ -98,8 +130,6 @@ export const apiMsAuth = async (signal?: AbortSignal): Promise => { const token = await response.text(); storeToken(token); return token; - } catch (err) { - throw err; } finally { tokenPromise = null; } @@ -113,69 +143,72 @@ export interface TranslateOptions { to: string; text: string; signal?: AbortSignal; + textType?: 'html' | 'plain'; } export const translateText = async (options: TranslateOptions): Promise => { - const { from, to, text, signal } = options; + const { from, to, text, signal, textType } = options; if (!text || text.trim() === '') { return { translatedText: text, detectedLanguage: '' }; } - const token = await apiMsAuth(signal); - - const params = queryString.stringify({ - ...(from && { from }), - to, - 'api-version': '3.0', - }); - - const url = `${TRANSLATE_API_URL}?${params}`; - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify([{ Text: text }]), - signal, - }); - - if (!response.ok) { - if (response.status === 401) { - cachedToken = null; - localStorage.removeItem('ms_translate_token'); + return withTranslateRetry(async (token) => { + const params = queryString.stringify({ + ...(from && { from }), + to, + 'api-version': '3.0', + ...(textType === 'html' && { textType: 'html' }), + }); + + const url = `${TRANSLATE_API_URL}?${params}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify([{ Text: text }]), + signal, + }); + + if (!response.ok) { + if (response.status === 401) { + cachedToken = null; + localStorage.removeItem('ms_translate_token'); + } + throw new Error(`Translation failed: ${response.status}`); } - throw new Error(`Translation failed: ${response.status}`); - } - const data = await response.json(); + const data = await response.json(); - if (!Array.isArray(data) || data.length === 0) { - throw new Error('Invalid translation response'); - } + if (!Array.isArray(data) || data.length === 0) { + throw new Error('Invalid translation response'); + } - const result = data[0]; - const translatedText = result.translations?.[0]?.text || text; - const detectedLanguage = result.detectedLanguage?.language || ''; + const result = data[0]; + const translatedText = result.translations?.[0]?.text || text; + const detectedLanguage = result.detectedLanguage?.language || ''; - return { - translatedText, - detectedLanguage, - }; + return { + translatedText, + detectedLanguage, + }; + }, signal, 3); }; export const translateBatch = async ( texts: string[], to: string, from?: string, - signal?: AbortSignal + signal?: AbortSignal, + textType?: 'html' | 'plain' ): Promise => { if (texts.length === 0) return []; if (texts.length === 1) { - const result = await translateText({ text: texts[0], to, from, signal }); + const result = await translateText({ text: texts[0], to, from, signal, textType }); return [result]; } @@ -194,7 +227,7 @@ export const translateBatch = async ( for (const text of batch) { if (currentLength + text.length > maxChars && currentBatch.length > 0) { - const batchResults = await translateBatchInternal(currentBatch, to, from, signal); + const batchResults = await translateBatchInternal(currentBatch, to, from, signal, textType); results.push(...batchResults); currentBatch = []; currentLength = 0; @@ -204,7 +237,7 @@ export const translateBatch = async ( } if (currentBatch.length > 0) { - const batchResults = await translateBatchInternal(currentBatch, to, from, signal); + const batchResults = await translateBatchInternal(currentBatch, to, from, signal, textType); results.push(...batchResults); } } @@ -216,46 +249,48 @@ const translateBatchInternal = async ( texts: string[], to: string, from?: string, - signal?: AbortSignal + signal?: AbortSignal, + textType?: 'html' | 'plain' ): Promise => { - const token = await apiMsAuth(signal); - - const params = queryString.stringify({ - ...(from && { from }), - to, - 'api-version': '3.0', - }); - - const url = `${TRANSLATE_API_URL}?${params}`; - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(texts.map(t => ({ Text: t }))), - signal, - }); - - if (!response.ok) { - if (response.status === 401) { - cachedToken = null; - localStorage.removeItem('ms_translate_token'); + return withTranslateRetry(async (token) => { + const params = queryString.stringify({ + ...(from && { from }), + to, + 'api-version': '3.0', + ...(textType === 'html' && { textType: 'html' }), + }); + + const url = `${TRANSLATE_API_URL}?${params}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(texts.map(t => ({ Text: t }))), + signal, + }); + + if (!response.ok) { + if (response.status === 401) { + cachedToken = null; + localStorage.removeItem('ms_translate_token'); + } + throw new Error(`Translation failed: ${response.status}`); } - throw new Error(`Translation failed: ${response.status}`); - } - const data = await response.json(); + const data = await response.json(); - if (!Array.isArray(data) || data.length !== texts.length) { - throw new Error('Invalid translation response'); - } + if (!Array.isArray(data) || data.length !== texts.length) { + throw new Error('Invalid translation response'); + } - return data.map((result, index) => ({ - translatedText: result.translations?.[0]?.text || texts[index], - detectedLanguage: result.detectedLanguage?.language || '', - })); + return data.map((result, index) => ({ + translatedText: result.translations?.[0]?.text || texts[index], + detectedLanguage: result.detectedLanguage?.language || '', + })); + }, signal, 3); }; export const clearTranslateCache = (): void => { diff --git a/src/utils/domTextScanner.ts b/src/utils/domTextScanner.ts new file mode 100644 index 00000000..fe2a6186 --- /dev/null +++ b/src/utils/domTextScanner.ts @@ -0,0 +1,149 @@ +export interface DomBlockSegment { + id: number; + element: HTMLElement; + text: string; + blockType: 'heading' | 'paragraph' | 'list-item' | 'blockquote' | 'table-cell' | 'other'; + tagName: string; + hasVisualContent: boolean; + hasInlineCode: boolean; +} + +const BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, th, td, dt, dd, figcaption'; + +function getBlockType(tagName: string): DomBlockSegment['blockType'] { + if (/^H[1-6]$/.test(tagName)) return 'heading'; + if (tagName === 'LI') return 'list-item'; + if (tagName === 'BLOCKQUOTE') return 'blockquote'; + if (tagName === 'TH' || tagName === 'TD') return 'table-cell'; + if (tagName === 'P') return 'paragraph'; + return 'other'; +} + +function hasVisualContent(element: HTMLElement): boolean { + return element.querySelectorAll('img, svg, pre, video, iframe, picture').length > 0; +} + +interface ExtractedText { + text: string; + hasInlineCode: boolean; +} + +function extractTextPreservingInlineCode(element: HTMLElement): ExtractedText { + let result = ''; + let hasInlineCode = false; + + function walk(node: Node): void { + if (node.nodeType === Node.TEXT_NODE) { + result += node.textContent || ''; + return; + } + + if (node.nodeType !== Node.ELEMENT_NODE) return; + + const el = node as HTMLElement; + const tag = el.tagName.toLowerCase(); + + if (tag === 'pre' || tag === 'img' || tag === 'svg' || tag === 'input' || tag === 'video' || tag === 'iframe' || tag === 'picture') { + return; + } + + if (tag === 'code' && !el.closest('pre')) { + hasInlineCode = true; + result += `${el.textContent || ''}`; + return; + } + + for (let i = 0; i < el.childNodes.length; i++) { + walk(el.childNodes[i]); + } + } + + walk(element); + return { text: result.trim(), hasInlineCode }; +} + +function isInsideSkippedElement(element: HTMLElement, root: HTMLElement): boolean { + let parent = element.parentElement; + while (parent && parent !== root) { + const tag = parent.tagName; + if (tag === 'PRE' || tag === 'CODE') return true; + parent = parent.parentElement; + } + return false; +} + +function filterOutermostBlocks(elements: HTMLElement[], container: HTMLElement): HTMLElement[] { + const elementSet = new Set(elements); + return elements.filter(el => { + let parent = el.parentElement; + while (parent && parent !== container) { + if (elementSet.has(parent)) return false; + parent = parent.parentElement; + } + return true; + }); +} + +export function scanDomForTranslation(container: HTMLElement): DomBlockSegment[] { + const segments: DomBlockSegment[] = []; + let idCounter = 0; + + const allElements = Array.from(container.querySelectorAll(BLOCK_SELECTOR)) as HTMLElement[]; + const elements = filterOutermostBlocks(allElements, container); + + for (const element of elements) { + if (isInsideSkippedElement(element, container)) continue; + + const extracted = extractTextPreservingInlineCode(element); + if (!extracted.text) continue; + + segments.push({ + id: idCounter++, + element, + text: extracted.text, + blockType: getBlockType(element.tagName), + tagName: element.tagName, + hasVisualContent: hasVisualContent(element), + hasInlineCode: extracted.hasInlineCode, + }); + } + + return segments; +} + +export function wrapTextNodesWithAttr(element: HTMLElement, attr: string, value: string): HTMLElement[] { + const spans: HTMLElement[] = []; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const replacements: { textNode: Text; parent: HTMLElement }[] = []; + + let node: Node | null; + while ((node = walker.nextNode())) { + const parent = node.parentElement; + if (!parent) continue; + if (parent.tagName === 'CODE' || parent.tagName === 'PRE' || parent.closest('pre')) continue; + if (!node.textContent?.trim()) continue; + replacements.push({ textNode: node as Text, parent }); + } + + for (const { textNode, parent } of replacements) { + const span = document.createElement('span'); + span.setAttribute(attr, value); + parent.replaceChild(span, textNode); + span.textContent = textNode.textContent; + spans.push(span); + } + + return spans; +} + +export function unwrapSpans(spans: HTMLElement[]): void { + for (const span of spans) { + const parent = span.parentNode; + if (parent) { + parent.replaceChild(document.createTextNode(span.textContent || ''), span); + } + } +} + +export const ATTR_ORIGINAL = 'data-bi-original'; +export const ATTR_TRANSLATION = 'data-bi-translation'; diff --git a/src/utils/markdownSplitter.ts b/src/utils/markdownSplitter.ts index b9e47ea2..af4395a4 100644 --- a/src/utils/markdownSplitter.ts +++ b/src/utils/markdownSplitter.ts @@ -1,140 +1,105 @@ -export interface TranslationSegment { - id: number; - originalContent: string; - translatedContent: string | null; - status: 'pending' | 'translating' | 'done' | 'error'; - hasCodeBlock: boolean; - hasImage: boolean; - separator: string; -} - -interface SplitResult { - segments: TranslationSegment[]; - placeholderMap: Map; -} - -let segmentIdCounter = 0; - -export const splitMarkdownSimple = (markdown: string): SplitResult => { - segmentIdCounter = 0; - const placeholderMap = new Map(); - let placeholderIndex = 0; - - const createPlaceholder = (type: string, content: string): string => { - const key = `__${type}_${placeholderIndex++}__`; - placeholderMap.set(key, content); - return key; - }; +export const cleanTranslatedText = (text: string): string => { + if (!text) return text; - let processed = markdown; - - processed = processed.replace(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g, (match) => { - return createPlaceholder('CODE', match); - }); - - processed = processed.replace(/`[^`\n]+`/g, (match) => { - return createPlaceholder('INLINE_CODE', match); - }); - - processed = processed.replace(/!\[[^\]]*\]\([^)]+\)/g, (match) => { - return createPlaceholder('IMG', match); - }); - - processed = processed.replace(/]*>\s*]*>[\s\S]*?<\/a>/gi, (match) => { - return createPlaceholder('LINKED_IMG', match); - }); - - processed = processed.replace(/]*>/gi, (match) => { - return createPlaceholder('HTML_IMG', match); - }); - - processed = processed.replace(//gi, (match) => { - return createPlaceholder('DETAILS', match); - }); - - const separatorPattern = /(\n{2,})/g; - const parts = processed.split(separatorPattern); - - const segments: TranslationSegment[] = []; - let currentSeparator = '\n\n'; - - for (let i = 0; i < parts.length; i += 2) { - const content = parts[i]; - const separator = parts[i + 1] || '\n\n'; - - if (content.trim()) { - const hasCodeBlock = content.includes('__CODE_'); - const hasImage = content.includes('__IMG_') || content.includes('__HTML_IMG_'); - - segments.push({ - id: segmentIdCounter++, - originalContent: content, - translatedContent: null, - status: 'pending' as const, - hasCodeBlock, - hasImage, - separator: currentSeparator, - }); - } - - currentSeparator = separator; - } + let cleaned = text; + + cleaned = cleaned.replace(/\u200B/g, ''); + cleaned = cleaned.replace(/\u200C/g, ''); + cleaned = cleaned.replace(/\u200D/g, ''); + cleaned = cleaned.replace(/\uFEFF/g, ''); + cleaned = cleaned.replace(/\u00A0/g, ' '); - return { segments, placeholderMap }; + cleaned = cleaned.replace(/[\uFF08\uFF09]/g, (match) => + match === '\uFF08' ? '(' : ')' + ); + cleaned = cleaned.replace(/[\uFF3B\uFF3D]/g, (match) => + match === '\uFF3B' ? '[' : ']' + ); + cleaned = cleaned.replace(/\uFF1A/g, ':'); + cleaned = cleaned.replace(/\uFF0C/g, ','); + cleaned = cleaned.replace(/\uFF1B/g, ';'); + cleaned = cleaned.replace(/\uFF01/g, '!'); + cleaned = cleaned.replace(/\uFF1F/g, '?'); + cleaned = cleaned.replace(/\u3001/g, ','); + + return cleaned; }; -export const restorePlaceholders = ( - text: string, - placeholderMap: Map -): string => { - if (!text || !placeholderMap.size) return text; - - let result = text; - - const entries = Array.from(placeholderMap.entries()); - - for (const [key, value] of entries) { - if (result.includes(key)) { - result = result.split(key).join(value); - continue; - } - - const coreId = key.replace(/^_+|_+$/g, ''); - const pattern = new RegExp(`_+${coreId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}_+`, 'gi'); - - if (pattern.test(result)) { - result = result.replace(pattern, value); - } - } - - return result; +export type DetectedLanguage = + | 'zh' + | 'en' + | 'ja' + | 'ko' + | 'fr' + | 'de' + | 'es' + | 'ru' + | 'pt' + | 'unknown'; + +const LANGUAGE_PATTERNS: { lang: DetectedLanguage; regex: RegExp }[] = [ + { lang: 'zh', regex: /[\u4e00-\u9fa5]/g }, + { lang: 'ja', regex: /[\u3040-\u309F\u30A0-\u30FF]/g }, + { lang: 'ko', regex: /[\uAC00-\uD7AF\u1100-\u11FF]/g }, + { lang: 'ru', regex: /[\u0400-\u04FF]/g }, + { lang: 'en', regex: /[a-zA-Z]/g }, +]; + +const LANGUAGE_NAMES: Record = { + zh: '中文', + en: 'English', + ja: '日本語', + ko: '한국어', + fr: 'Français', + de: 'Deutsch', + es: 'Español', + ru: 'Русский', + pt: 'Português', + unknown: 'Unknown', }; -export const detectLanguage = (content: string): 'zh' | 'en' | 'unknown' => { - const chineseCharRegex = /[\u4e00-\u9fa5]/g; - const englishCharRegex = /[a-zA-Z]/g; - - const chineseChars = content.match(chineseCharRegex); - const englishChars = content.match(englishCharRegex); - - const chineseCount = chineseChars ? chineseChars.length : 0; - const englishCount = englishChars ? englishChars.length : 0; - - if (chineseCount === 0 && englishCount === 0) { - return 'unknown'; +export const getLanguageName = (lang: DetectedLanguage): string => + LANGUAGE_NAMES[lang]; + +export const detectLanguage = (content: string): DetectedLanguage => { + const scores: { lang: DetectedLanguage; count: number }[] = []; + + for (const { lang, regex } of LANGUAGE_PATTERNS) { + const matches = content.match(regex); + scores.push({ lang, count: matches ? matches.length : 0 }); } - - const chineseRatio = chineseCount / (chineseCount + englishCount); - - if (chineseRatio > 0.3) { - return 'zh'; + + scores.sort((a, b) => b.count - a.count); + + const top = scores[0]; + if (!top || top.count === 0) { + return 'unknown'; } - - return 'en'; + + return top.lang; +}; + +export const isEnglishText = (text: string): boolean => { + const trimmed = text.trim(); + if (!trimmed) return true; + + const latinChars = trimmed.match(/[a-zA-Z]/g); + const cyrillicChars = trimmed.match(/[\u0400-\u04FF]/g); + const cjkChars = trimmed.match( + /[\u4e00-\u9fa5\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF\u1100-\u11FF]/g + ); + + const latinCount = latinChars ? latinChars.length : 0; + const nonLatinCount = + (cyrillicChars ? cyrillicChars.length : 0) + + (cjkChars ? cjkChars.length : 0); + + if (latinCount === 0 && nonLatinCount === 0) return true; + return latinCount > nonLatinCount; }; export const getTranslateDirection = ( - detected: 'zh' | 'en' | 'unknown', + detected: DetectedLanguage, target: 'zh' | 'en' ): { from?: string; to: string } => { if (detected === 'unknown') { @@ -145,38 +110,3 @@ export const getTranslateDirection = ( to: target, }; }; - -export const segmentsToMarkdown = (segments: TranslationSegment[]): string => { - if (segments.length === 0) return ''; - - return segments.reduce((acc, segment, index) => { - const content = segment.translatedContent || segment.originalContent; - if (index === 0) { - return content; - } - return acc + segment.separator + content; - }, ''); -}; - -export const cleanTranslatedText = (text: string): string => { - if (!text) return text; - - let cleaned = text; - - cleaned = cleaned.replace(/\u200B/g, ''); - cleaned = cleaned.replace(/\u200C/g, ''); - cleaned = cleaned.replace(/\u200D/g, ''); - cleaned = cleaned.replace(/\uFEFF/g, ''); - cleaned = cleaned.replace(/\u00A0/g, ' '); - - cleaned = cleaned.replace(/[\uFF08\uFF09]/g, (match) => match === '\uFF08' ? '(' : ')'); - cleaned = cleaned.replace(/[\uFF3B\uFF3D]/g, (match) => match === '\uFF3B' ? '[' : ']'); - - const chinesePunctuation = '[。,、;:?!》《〕」』】)…—~·]'; - cleaned = cleaned.replace( - new RegExp(`(https?://[^\\s<>${chinesePunctuation}]*)(${chinesePunctuation})`, 'gi'), - '$1 $2' - ); - - return cleaned; -}; From 3db1bf8e80e00dbe29d65f77bb5cd8b9fb2d7ff1 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Mon, 4 May 2026 05:17:49 +0800 Subject: [PATCH 18/23] =?UTF-8?q?fix(BilingualMarkdownRenderer):=20?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=A4=84=E7=90=86=E5=86=85=E8=81=94=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=8A=82=E7=82=B9=E7=9A=84HTML=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加appendSafeHTMLNodes函数来安全处理翻译文本中的内联代码节点,防止潜在的HTML注入风险。当文本包含内联代码时,不再直接设置innerHTML,而是递归复制安全节点。 --- src/components/BilingualMarkdownRenderer.tsx | 22 +++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/components/BilingualMarkdownRenderer.tsx b/src/components/BilingualMarkdownRenderer.tsx index a0ca7ca5..5616f831 100644 --- a/src/components/BilingualMarkdownRenderer.tsx +++ b/src/components/BilingualMarkdownRenderer.tsx @@ -53,6 +53,24 @@ const BILINGUAL_MODE_CSS = ` } `; +function appendSafeHTMLNodes(target: HTMLElement, source: HTMLElement): void { + for (let i = 0; i < source.childNodes.length; i++) { + const child = source.childNodes[i]; + if (child.nodeType === Node.TEXT_NODE) { + target.appendChild(document.createTextNode(child.textContent || '')); + } else if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as HTMLElement; + if (el.tagName.toLowerCase() === 'code') { + const codeEl = document.createElement('code'); + codeEl.textContent = el.textContent || ''; + target.appendChild(codeEl); + } else { + appendSafeHTMLNodes(target, el); + } + } + } +} + const BilingualMarkdownRenderer = forwardRef(({ markdown, baseUrl, @@ -199,7 +217,9 @@ const BilingualMarkdownRenderer = forwardRef Date: Mon, 4 May 2026 05:35:50 +0800 Subject: [PATCH 19/23] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E5=8A=9F=E8=83=BD=E4=B8=AD=E7=9A=84=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复翻译服务中的重试逻辑,仅对瞬态错误进行重试 优化双语渲染器中的代码块处理逻辑 更新DOM文本扫描器以包含summary标签 在ReadmeModal中重置翻译标题映射 --- src/components/BilingualMarkdownRenderer.tsx | 44 ++++++++++---------- src/components/ReadmeModal.tsx | 1 + src/services/translateService.ts | 30 ++++++++++++- src/utils/domTextScanner.ts | 2 +- 4 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/components/BilingualMarkdownRenderer.tsx b/src/components/BilingualMarkdownRenderer.tsx index 5616f831..4e212e95 100644 --- a/src/components/BilingualMarkdownRenderer.tsx +++ b/src/components/BilingualMarkdownRenderer.tsx @@ -53,24 +53,6 @@ const BILINGUAL_MODE_CSS = ` } `; -function appendSafeHTMLNodes(target: HTMLElement, source: HTMLElement): void { - for (let i = 0; i < source.childNodes.length; i++) { - const child = source.childNodes[i]; - if (child.nodeType === Node.TEXT_NODE) { - target.appendChild(document.createTextNode(child.textContent || '')); - } else if (child.nodeType === Node.ELEMENT_NODE) { - const el = child as HTMLElement; - if (el.tagName.toLowerCase() === 'code') { - const codeEl = document.createElement('code'); - codeEl.textContent = el.textContent || ''; - target.appendChild(codeEl); - } else { - appendSafeHTMLNodes(target, el); - } - } - } -} - const BilingualMarkdownRenderer = forwardRef(({ markdown, baseUrl, @@ -208,6 +190,8 @@ const BilingualMarkdownRenderer = forwardRef([\s\S]*?)<\/code>/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = codeRegex.exec(translatedTexts[i])) !== null) { + if (match.index > lastIndex) { + wrapper.appendChild(document.createTextNode(translatedTexts[i].slice(lastIndex, match.index))); + } + const codeEl = document.createElement('code'); + codeEl.textContent = match[1]; + wrapper.appendChild(codeEl); + lastIndex = codeRegex.lastIndex; + } + if (lastIndex < translatedTexts[i].length) { + wrapper.appendChild(document.createTextNode(translatedTexts[i].slice(lastIndex))); + } } else { wrapper.textContent = translatedTexts[i]; } @@ -228,7 +224,11 @@ const BilingualMarkdownRenderer = forwardRef = ({ const { items, idMap } = extractToc(displayContent); setTocItems(items); setHeadingIdMap(idMap); + setTranslatedHeadingMap(new Map()); } }, [displayContent, extractToc]); diff --git a/src/services/translateService.ts b/src/services/translateService.ts index 11f3b572..8e2cf0ee 100644 --- a/src/services/translateService.ts +++ b/src/services/translateService.ts @@ -71,6 +71,28 @@ const storeToken = (token: string): void => { const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +const extractHttpStatus = (err: unknown): number | null => { + const anyErr = err as Record; + const response = anyErr?.response as Record | undefined; + const status = response?.status ?? anyErr?.status; + if (typeof status === 'number') return status; + + if (err instanceof Error) { + const match = err.message.match(/(?:status|failed)[:\s]*(\d{3})/i); + if (match) { + return parseInt(match[1], 10); + } + } + + return null; +}; + +const isTransientError = (err: unknown): boolean => { + const status = extractHttpStatus(err); + if (status === null) return true; + return status === 429 || status >= 500; +}; + const withTranslateRetry = async ( operation: (token: string) => Promise, signal?: AbortSignal, @@ -91,9 +113,13 @@ const withTranslateRetry = async ( throw err; } - if (attempt < maxRetries) { - await sleep(baseDelay * Math.pow(2, attempt - 1)); + if (attempt >= maxRetries) break; + + if (!isTransientError(err)) { + throw err; } + + await sleep(baseDelay * Math.pow(2, attempt - 1)); } } diff --git a/src/utils/domTextScanner.ts b/src/utils/domTextScanner.ts index fe2a6186..eb90466d 100644 --- a/src/utils/domTextScanner.ts +++ b/src/utils/domTextScanner.ts @@ -8,7 +8,7 @@ export interface DomBlockSegment { hasInlineCode: boolean; } -const BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, th, td, dt, dd, figcaption'; +const BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, th, td, dt, dd, figcaption, summary'; function getBlockType(tagName: string): DomBlockSegment['blockType'] { if (/^H[1-6]$/.test(tagName)) return 'heading'; From 983c5b60a8d0f47022c45d22dc7dfb3c469cce6c Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Mon, 4 May 2026 06:04:09 +0800 Subject: [PATCH 20/23] =?UTF-8?q?fix(=E7=BF=BB=E8=AF=91=E6=9C=8D=E5=8A=A1)?= =?UTF-8?q?:=20=E4=BF=AE=E5=A4=8D=E8=AE=A4=E8=AF=81=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=92=8C=E7=AB=9E=E6=80=81=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复翻译服务中的认证过期处理,添加AuthExpiredError类标记认证过期错误 优化token获取逻辑,避免竞态条件和重复请求 添加对abort signal的支持,确保请求可被正确取消 --- src/components/BilingualMarkdownRenderer.tsx | 8 ++- src/components/MarkdownRenderer.tsx | 17 +++--- src/components/ReadmeModal.tsx | 11 +++- src/services/translateService.ts | 64 +++++++++++++------- src/utils/domTextScanner.ts | 16 ++++- 5 files changed, 84 insertions(+), 32 deletions(-) diff --git a/src/components/BilingualMarkdownRenderer.tsx b/src/components/BilingualMarkdownRenderer.tsx index 4e212e95..564cbdab 100644 --- a/src/components/BilingualMarkdownRenderer.tsx +++ b/src/components/BilingualMarkdownRenderer.tsx @@ -53,6 +53,12 @@ const BILINGUAL_MODE_CSS = ` } `; +function decodeHtmlEntities(text: string): string { + const textarea = document.createElement('textarea'); + textarea.innerHTML = text; + return textarea.value; +} + const BilingualMarkdownRenderer = forwardRef(({ markdown, baseUrl, @@ -209,7 +215,7 @@ const BilingualMarkdownRenderer = forwardRef }; const extractTextFromChildren = (children: React.ReactNode): string => { - if (typeof children === 'string') return children; - if (typeof children === 'number') return String(children); - if (Array.isArray(children)) return children.map(extractTextFromChildren).join(''); - if (React.isValidElement(children)) { - return extractTextFromChildren((children.props as { children?: React.ReactNode }).children); - } - return ''; + const inner = (children: React.ReactNode): string => { + if (typeof children === 'string') return children; + if (typeof children === 'number') return String(children); + if (Array.isArray(children)) return children.map(inner).join(''); + if (React.isValidElement(children)) { + return inner((children.props as { children?: React.ReactNode }).children); + } + return ''; + }; + return inner(children).replace(/\s+/g, ' ').trim(); }; const MarkdownRenderer: React.FC = memo(({ diff --git a/src/components/ReadmeModal.tsx b/src/components/ReadmeModal.tsx index 4cb2efde..1c33e153 100644 --- a/src/components/ReadmeModal.tsx +++ b/src/components/ReadmeModal.tsx @@ -187,7 +187,8 @@ export const ReadmeModal: React.FC = ({ const topEntry = visibleEntries.reduce((a, b) => a.boundingClientRect.top < b.boundingClientRect.top ? a : b ); - setActiveHeadingId(topEntry.target.id); + const target = topEntry.target as HTMLElement; + setActiveHeadingId(target.dataset.biHeadingId ?? target.id); } }, { @@ -198,7 +199,10 @@ export const ReadmeModal: React.FC = ({ ); tocItems.forEach((item) => { - let el = container.querySelector(`#${CSS.escape(item.id)}`); + let el = container.querySelector(`[data-bi-heading-id="${CSS.escape(item.id)}"]`) as HTMLElement | null; + if (!el) { + el = container.querySelector(`#${CSS.escape(item.id)}`); + } if (!el && item.text) { const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6'); for (let i = 0; i < headings.length; i++) { @@ -366,6 +370,9 @@ export const ReadmeModal: React.FC = ({ setTranslateProgress({ current: 0, total: 0 }); setTranslateError(null); setTranslatedHeadingMap(new Map()); + isResizingRef.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; } else { setShowToc(true); } diff --git a/src/services/translateService.ts b/src/services/translateService.ts index 8e2cf0ee..0b26f313 100644 --- a/src/services/translateService.ts +++ b/src/services/translateService.ts @@ -1,5 +1,13 @@ import queryString from 'query-string'; +class AuthExpiredError extends Error { + readonly isAuthExpired = true; + constructor() { + super('Auth expired'); + this.name = 'AuthExpiredError'; + } +} + interface TranslateResult { translatedText: string; detectedLanguage: string; @@ -88,6 +96,7 @@ const extractHttpStatus = (err: unknown): number | null => { }; const isTransientError = (err: unknown): boolean => { + if ((err as { isAuthExpired?: boolean })?.isAuthExpired) return true; const status = extractHttpStatus(err); if (status === null) return true; return status === 429 || status >= 500; @@ -137,31 +146,42 @@ export const apiMsAuth = async (signal?: AbortSignal): Promise => { return cachedToken.token; } - if (tokenPromise) { + if (!tokenPromise) { + tokenPromise = (async () => { + try { + const response = await fetch(AUTH_URL, { + method: 'GET', + credentials: 'omit', + }); + + if (!response.ok) { + throw new Error(`Auth failed: ${response.status}`); + } + + const token = await response.text(); + storeToken(token); + return token; + } finally { + tokenPromise = null; + } + })(); + } + + if (!signal) { return tokenPromise; } - tokenPromise = (async () => { - try { - const response = await fetch(AUTH_URL, { - method: 'GET', - credentials: 'omit', - signal, - }); - - if (!response.ok) { - throw new Error(`Auth failed: ${response.status}`); + return Promise.race([ + tokenPromise, + new Promise((_, reject) => { + if (signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; } - - const token = await response.text(); - storeToken(token); - return token; - } finally { - tokenPromise = null; - } - })(); - - return tokenPromise; + const onAbort = () => reject(new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', onAbort, { once: true }); + }), + ]); }; export interface TranslateOptions { @@ -203,6 +223,7 @@ export const translateText = async (options: TranslateOptions): Promise/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function extractTextPreservingInlineCode(element: HTMLElement): ExtractedText { let result = ''; let hasInlineCode = false; @@ -49,10 +58,15 @@ function extractTextPreservingInlineCode(element: HTMLElement): ExtractedText { if (tag === 'code' && !el.closest('pre')) { hasInlineCode = true; - result += `${el.textContent || ''}`; + result += `${escapeHtml(el.textContent || '')}`; return; } + const blockTags = /^(p|h[1-6]|li|blockquote|th|td|dt|dd|figcaption|summary)$/i; + if (blockTags.test(tag) && result.length > 0 && !result.endsWith('\n\n')) { + result += '\n\n'; + } + for (let i = 0; i < el.childNodes.length; i++) { walk(el.childNodes[i]); } From 6a39a2d32e80f63c1c2e8001044f49cfe3f761b7 Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Mon, 4 May 2026 06:25:15 +0800 Subject: [PATCH 21/23] =?UTF-8?q?feat(=E7=BF=BB=E8=AF=91):=20=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E7=BF=BB=E8=AF=91=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=BB=9A=E5=8A=A8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在ReadmeModal中添加对可见元素的检查,防止滚动到隐藏元素 - 改进翻译服务,支持大文本分块处理和取消操作 - 优化双语Markdown渲染器,区分处理纯文本和HTML内容 - 导出TranslateResult接口供外部使用 --- src/components/BilingualMarkdownRenderer.tsx | 51 ++++++++++++---- src/components/ReadmeModal.tsx | 4 +- src/services/translateService.ts | 63 ++++++++++++++++++-- 3 files changed, 100 insertions(+), 18 deletions(-) diff --git a/src/components/BilingualMarkdownRenderer.tsx b/src/components/BilingualMarkdownRenderer.tsx index 564cbdab..e66caff2 100644 --- a/src/components/BilingualMarkdownRenderer.tsx +++ b/src/components/BilingualMarkdownRenderer.tsx @@ -8,7 +8,7 @@ import { wrapTextNodesWithAttr, unwrapSpans, } from '../utils/domTextScanner'; -import { translateBatch } from '../services/translateService'; +import { translateBatch, TranslateResult } from '../services/translateService'; import { detectLanguage, getTranslateDirection, cleanTranslatedText } from '../utils/markdownSplitter'; import { FileText, Languages, Eye, Loader2 } from 'lucide-react'; @@ -134,7 +134,9 @@ const BilingualMarkdownRenderer = forwardRef s.text).filter(Boolean); + const sampleText = segmentTexts.slice(0, 20).join(' '); + const detected = detectLanguage(sampleText); const targetLang = language; if (detected === targetLang) { @@ -182,18 +184,43 @@ const BilingualMarkdownRenderer = forwardRef segments[j].hasInlineCode); - const textType = batchHasInlineCode ? 'html' as const : 'plain' as const; - - const results = await translateBatch(batchTexts, direction.to, direction.from, signal, textType); + const htmlIndices: number[] = []; + const htmlTexts: string[] = []; + const plainIndices: number[] = []; + const plainTexts: string[] = []; + + for (let k = 0; k < batchIndices.length; k++) { + const j = batchIndices[k]; + if (segments[j].hasInlineCode) { + htmlIndices.push(j); + htmlTexts.push(segments[j].text); + } else { + plainIndices.push(j); + plainTexts.push(segments[j].text); + } + } - batchIndices.forEach((segIndex, resultIndex) => { - translatedTexts[segIndex] = cleanTranslatedText(results[resultIndex]?.translatedText || ''); - }); + const processResults = (indices: number[], results: TranslateResult[]) => { + indices.forEach((segIndex, resultIndex) => { + translatedTexts[segIndex] = cleanTranslatedText(results[resultIndex]?.translatedText || ''); + }); + }; + + if (htmlTexts.length > 0) { + const htmlResults = await translateBatch(htmlTexts, direction.to, direction.from, signal, 'html'); + processResults(htmlIndices, htmlResults); + completedCount += htmlIndices.length; + setProgress({ current: completedCount, total: segments.length }); + onProgress?.(completedCount, segments.length); + } - completedCount += batchIndices.length; - setProgress({ current: completedCount, total: segments.length }); - onProgress?.(completedCount, segments.length); + if (plainTexts.length > 0) { + const plainResults = await translateBatch(plainTexts, direction.to, direction.from, signal, 'plain'); + processResults(plainIndices, plainResults); + completedCount += plainIndices.length; + setProgress({ current: completedCount, total: segments.length }); + onProgress?.(completedCount, segments.length); + } } const inlineContainerTags = new Set(['LI', 'TD', 'TH', 'DT', 'DD']); diff --git a/src/components/ReadmeModal.tsx b/src/components/ReadmeModal.tsx index 1c33e153..4278708c 100644 --- a/src/components/ReadmeModal.tsx +++ b/src/components/ReadmeModal.tsx @@ -108,7 +108,7 @@ export const ReadmeModal: React.FC = ({ const container = contentRef.current; const translationWrapper = container.querySelector(`[data-bi-heading-id="${CSS.escape(id)}"]`) as HTMLElement | null; - if (translationWrapper) { + if (translationWrapper && translationWrapper.offsetParent !== null) { const elementRect = translationWrapper.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const scrollTop = container.scrollTop + elementRect.top - containerRect.top - 20; @@ -221,7 +221,7 @@ export const ReadmeModal: React.FC = ({ clearTimeout(timer); if (observer) observer.disconnect(); }; - }, [tocItems, readmeContent]); + }, [tocItems, readmeContent, translateStatus, displayMode]); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { diff --git a/src/services/translateService.ts b/src/services/translateService.ts index 0b26f313..5c25728f 100644 --- a/src/services/translateService.ts +++ b/src/services/translateService.ts @@ -8,7 +8,7 @@ class AuthExpiredError extends Error { } } -interface TranslateResult { +export interface TranslateResult { translatedText: string; detectedLanguage: string; } @@ -76,8 +76,21 @@ const storeToken = (token: string): void => { } }; -const sleep = (ms: number): Promise => - new Promise((resolve) => setTimeout(resolve, ms)); +const sleep = (ms: number, signal?: AbortSignal): Promise => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + const id = setTimeout(resolve, ms); + if (signal) { + const onAbort = () => { + clearTimeout(id); + reject(new DOMException('Aborted', 'AbortError')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + } + }); const extractHttpStatus = (err: unknown): number | null => { const anyErr = err as Record; @@ -128,7 +141,7 @@ const withTranslateRetry = async ( throw err; } - await sleep(baseDelay * Math.pow(2, attempt - 1)); + await sleep(baseDelay * Math.pow(2, attempt - 1), signal); } } @@ -245,6 +258,39 @@ export const translateText = async (options: TranslateOptions): Promise maxChars && current.length > 0) { + chunks.push(current); + current = para; + } else if (current.length > 0) { + current += '\n' + para; + } else { + current = para; + } + + while (current.length > maxChars) { + const splitPoint = current.lastIndexOf(' ', maxChars); + if (splitPoint <= 0) { + chunks.push(current.slice(0, maxChars)); + current = current.slice(maxChars); + } else { + chunks.push(current.slice(0, splitPoint)); + current = current.slice(splitPoint + 1); + } + } + } + + if (current) chunks.push(current); + return chunks; +} + export const translateBatch = async ( texts: string[], to: string, @@ -273,6 +319,15 @@ export const translateBatch = async ( let currentLength = 0; for (const text of batch) { + if (text.length > maxChars && currentBatch.length === 0) { + const chunks = splitTextIntoChunks(text, maxChars); + for (const chunk of chunks) { + const batchResults = await translateBatchInternal([chunk], to, from, signal, textType); + results.push(...batchResults); + } + continue; + } + if (currentLength + text.length > maxChars && currentBatch.length > 0) { const batchResults = await translateBatchInternal(currentBatch, to, from, signal, textType); results.push(...batchResults); From 57b585542fcbe3ac3b5ae81b82171e8bc617cb8d Mon Sep 17 00:00:00 2001 From: HappySummer <141414769+SummerRay160@users.noreply.github.com> Date: Mon, 4 May 2026 06:36:19 +0800 Subject: [PATCH 22/23] Update src/services/translateService.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/services/translateService.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/services/translateService.ts b/src/services/translateService.ts index 5c25728f..749bbc4c 100644 --- a/src/services/translateService.ts +++ b/src/services/translateService.ts @@ -319,7 +319,14 @@ export const translateBatch = async ( let currentLength = 0; for (const text of batch) { - if (text.length > maxChars && currentBatch.length === 0) { + // Always flush accumulated batch before handling an oversized item. + if (text.length > maxChars) { + if (currentBatch.length > 0) { + const batchResults = await translateBatchInternal(currentBatch, to, from, signal, textType); + results.push(...batchResults); + currentBatch = []; + currentLength = 0; + } const chunks = splitTextIntoChunks(text, maxChars); for (const chunk of chunks) { const batchResults = await translateBatchInternal([chunk], to, from, signal, textType); @@ -329,6 +336,7 @@ export const translateBatch = async ( } if (currentLength + text.length > maxChars && currentBatch.length > 0) { + // (this branch is now only reached for non-oversized items) const batchResults = await translateBatchInternal(currentBatch, to, from, signal, textType); results.push(...batchResults); currentBatch = []; From 055d64adbb011ea3c430f3a02b0e62b8c907e7af Mon Sep 17 00:00:00 2001 From: Happy Summer <141414769+SummerRay160@users.noreply.github.com> Date: Wed, 6 May 2026 12:25:32 +0800 Subject: [PATCH 23/23] =?UTF-8?q?feat(=E7=BF=BB=E8=AF=91):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0data-translate=E5=B1=9E=E6=80=A7=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BB=A5=E8=B7=B3=E8=BF=87=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在domTextScanner和MarkdownRenderer中添加对data-translate="false"属性的检查,当元素标记为不翻译时跳过处理。这用于避免翻译图片下方的操作提示文本等特定内容。 --- src/components/MarkdownRenderer.tsx | 3 ++- src/utils/domTextScanner.ts | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/MarkdownRenderer.tsx b/src/components/MarkdownRenderer.tsx index 4ea5ff0a..5ec1e271 100644 --- a/src/components/MarkdownRenderer.tsx +++ b/src/components/MarkdownRenderer.tsx @@ -549,7 +549,7 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }>
{!isLoading && !hasError && ( -
+
{isInsideLink ? (language === 'zh' ? '单击放大 · Ctrl+点击打开链接' : 'Click to zoom · Ctrl+Click to open link') @@ -567,6 +567,7 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }> {!isLoading && !hasError && isInsideLink && parentLinkHref && (
{ e.stopPropagation(); diff --git a/src/utils/domTextScanner.ts b/src/utils/domTextScanner.ts index 3217d669..a12a6dd0 100644 --- a/src/utils/domTextScanner.ts +++ b/src/utils/domTextScanner.ts @@ -56,6 +56,10 @@ function extractTextPreservingInlineCode(element: HTMLElement): ExtractedText { return; } + if (el.hasAttribute('data-translate') && el.getAttribute('data-translate') === 'false') { + return; + } + if (tag === 'code' && !el.closest('pre')) { hasInlineCode = true; result += `${escapeHtml(el.textContent || '')}`; @@ -135,6 +139,7 @@ export function wrapTextNodesWithAttr(element: HTMLElement, attr: string, value: const parent = node.parentElement; if (!parent) continue; if (parent.tagName === 'CODE' || parent.tagName === 'PRE' || parent.closest('pre')) continue; + if (parent.closest('[data-translate="false"]')) continue; if (!node.textContent?.trim()) continue; replacements.push({ textNode: node as Text, parent }); }