-
-
{t('正在加载文件内容...', 'Loading file content...')}
+
+
+ {t('正在加载文件内容...', 'Loading file content...')}
);
}
if (needsRawFetch && rawError) {
return (
-
-
-
{rawError}
-
+
+ {rawError}
+ {
setRawContent(null);
setRetryTick(tick => tick + 1);
}}
- className="inline-flex items-center gap-2 rounded-lg bg-brand-indigo px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-indigo/90"
+ className="inline-flex items-center gap-2 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{t('重试', 'Retry')}
-
+
);
}
return (
-
-
+
+
{content || ''}
@@ -131,14 +132,33 @@ export const GistDetailModal: React.FC = ({ gist, isOpen,
const updateGist = useAppStore(state => state.updateGist);
const { toast } = useDialog();
const [activeFilename, setActiveFilename] = useState('');
+ const [loadedContents, setLoadedContents] = useState>({});
+ const previousGistIdRef = useRef(null);
const t = (zh: string, en: string) => language === 'zh' ? zh : en;
const files = useMemo(() => Object.values(gist?.files || {}), [gist]);
const activeFile = files.find(file => file.filename === activeFilename) || files[0];
+ const loadedContentKey = gist && activeFile
+ ? `${gist.id}:${activeFile.filename}:${activeFile.raw_url ?? ''}`
+ : null;
+ const loadedActiveContent = loadedContentKey ? loadedContents[loadedContentKey] : undefined;
+ const hasLoadedActiveContent = Boolean(loadedContentKey && Object.prototype.hasOwnProperty.call(loadedContents, loadedContentKey));
+ const effectiveActiveFile = activeFile && hasLoadedActiveContent
+ ? { ...activeFile, content: loadedActiveContent, truncated: false }
+ : activeFile;
+ const requiresLoadedContent = Boolean(activeFile && !hasLoadedActiveContent && (
+ activeFile.truncated || (!activeFile.content && activeFile.raw_url)
+ ));
+ const canCopyActiveFile = Boolean(effectiveActiveFile) && !requiresLoadedContent;
+ const activeCopyContent = effectiveActiveFile
+ ? effectiveActiveFile.content ?? ''
+ : '';
// 截断文件按需拉取到的 raw 内容回写 store,避免每次重开弹窗都重新请求。
- const handleContentLoaded = (filename: string, content: string) => {
+ const handleContentLoaded = (filename: string, content: string, rawUrl?: string) => {
if (!gist) return;
+ const cacheKey = `${gist.id}:${filename}:${rawUrl ?? ''}`;
+ setLoadedContents(previous => ({ ...previous, [cacheKey]: content }));
const state = useAppStore.getState();
const latest =
state.gists.find(item => item.id === gist.id) ||
@@ -147,18 +167,29 @@ export const GistDetailModal: React.FC = ({ gist, isOpen,
gist;
const targetFile = latest.files?.[filename];
- if (!targetFile || (targetFile.content && !targetFile.truncated)) return;
+ if (!targetFile || (rawUrl && targetFile.raw_url && targetFile.raw_url !== rawUrl)) return;
+ if (targetFile.content && !targetFile.truncated) return;
updateGist({
...latest,
- files: {
- ...latest.files,
- [filename]: { ...targetFile, content },
- },
+ files: {
+ ...latest.files,
+ [filename]: { ...targetFile, content, truncated: false },
+ },
});
};
useEffect(() => {
- setActiveFilename(files[0]?.filename || '');
+ const gistChanged = previousGistIdRef.current !== gist?.id;
+ previousGistIdRef.current = gist?.id ?? null;
+ setActiveFilename((currentFilename) => {
+ if (gistChanged) return files[0]?.filename || '';
+ return currentFilename && files.some((file) => file.filename === currentFilename)
+ ? currentFilename
+ : files[0]?.filename || '';
+ });
+ if (gistChanged) {
+ setLoadedContents({});
+ }
}, [gist?.id, files]);
const handleCopy = async (text: string, message: string) => {
@@ -170,29 +201,29 @@ export const GistDetailModal: React.FC = ({ gist, isOpen,
return (
-
+
-
+
{gist.owner?.login || t('未知创建者', 'Unknown owner')}
·
{t('更新于', 'Updated')} {new Date(gist.updated_at).toLocaleString()}
·
{gist.public ? t('公开', 'Public') : t('私有', 'Secret')}
-
-
+ handleCopy(gist.html_url, t('链接已复制', 'Link copied'))}
- className="inline-flex items-center gap-2 rounded-lg border border-black/[0.06] bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-light-surface dark:border-white/[0.04] dark:bg-white/[0.04] dark:text-text-secondary dark:hover:bg-white/[0.08]"
+ className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted dark:border-border dark:bg-muted/40 dark:text-muted-foreground dark:hover:bg-accent"
>
{t('复制链接', 'Copy link')}
-
+
{t('打开', 'Open')}
@@ -201,51 +232,57 @@ export const GistDetailModal: React.FC = ({ gist, isOpen,
{gist.ai_summary && (
-
+
{gist.ai_summary}
)}
{files.map(file => (
- setActiveFilename(file.filename)}
- className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
+ className={`min-w-0 max-w-full break-all rounded-lg border px-3 py-1.5 text-left text-sm transition-colors ${
activeFile?.filename === file.filename
- ? 'border-brand-indigo bg-brand-indigo text-white'
- : 'border-black/[0.06] bg-white text-gray-700 hover:bg-light-surface dark:border-white/[0.04] dark:bg-white/[0.04] dark:text-text-secondary dark:hover:bg-white/[0.08]'
+ ? 'border-primary bg-primary text-primary-foreground'
+ : 'border-border bg-card text-muted-foreground hover:bg-muted dark:border-border dark:bg-muted/40 dark:text-muted-foreground dark:hover:bg-accent'
}`}
>
{file.filename}
-
+
))}
{activeFile ? (
-
+
-
{activeFile.filename}
-
+
{activeFile.filename}
+
{activeFile.language || inferGistCodeLanguage(activeFile.filename)} · {activeFile.size.toLocaleString()} bytes
{activeFile.truncated ? ` · ${t('内容已截断', 'Content truncated')}` : ''}
-
handleCopy(activeFile.content || '', t('文件内容已复制', 'File copied'))}
- className="inline-flex items-center gap-2 rounded-lg border border-black/[0.06] bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-light-surface dark:border-white/[0.04] dark:bg-white/[0.04] dark:text-text-secondary dark:hover:bg-white/[0.08]"
+ disabled={!canCopyActiveFile}
+ onClick={() => handleCopy(activeCopyContent, t('文件内容已复制', 'File copied'))}
+ className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted dark:border-border dark:bg-muted/40 dark:text-muted-foreground dark:hover:bg-accent"
>
{t('复制文件', 'Copy file')}
-
+
-
+
) : (
-
+
{t('没有文件', 'No files')}
)}
diff --git a/src/components/GistEditorModal.tsx b/src/components/GistEditorModal.tsx
index a352319b..6c8b109c 100644
--- a/src/components/GistEditorModal.tsx
+++ b/src/components/GistEditorModal.tsx
@@ -1,3 +1,7 @@
+import { Textarea } from './ui/textarea';
+import { Input } from './ui/input';
+import { Button } from './ui/button';
+import { Checkbox } from './ui/checkbox';
import React, { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Modal } from './Modal';
@@ -121,71 +125,74 @@ export const GistEditorModal: React.FC
= ({ gist, isOpen,
>
-
+
{t('描述', 'Description')}
- setDescription(event.target.value)}
- className="w-full rounded-lg border border-black/[0.06] bg-white px-3 py-2 text-gray-900 outline-none transition-colors focus:border-brand-indigo dark:border-white/[0.04] dark:bg-white/[0.04] dark:text-text-primary"
+ className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground outline-none transition-colors focus:border-primary dark:border-border dark:bg-muted/40 dark:text-foreground"
placeholder={t('这个 gist 是做什么的?', 'What is this gist for?')}
/>
{!gist && (
-
- setIsPublic(event.target.checked)}
- className="h-4 w-4 rounded border-gray-300 text-brand-indigo focus:ring-brand-indigo"
- />
- {t('公开 Gist', 'Public gist')}
-
+
+ setIsPublic(checked === true)} />
+
+ {t('公开 Gist', 'Public gist')}
+
+
)}
-
{t('文件', 'Files')}
+
{t('文件', 'Files')}
{hasDuplicateFilenames && (
{t('文件名不能重复', 'Filenames must be unique')}
)}
-
setFiles(prev => [...prev, createEmptyFile()])}
- className="inline-flex items-center gap-2 rounded-lg border border-black/[0.06] bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-light-surface dark:border-white/[0.04] dark:bg-white/[0.04] dark:text-text-secondary dark:hover:bg-white/[0.08]"
+ className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted dark:border-border dark:bg-muted/40 dark:text-muted-foreground dark:hover:bg-accent"
>
{t('添加文件', 'Add file')}
-
+
{visibleFiles.map((file, index) => (
-
+
@@ -193,21 +200,21 @@ export const GistEditorModal: React.FC
= ({ gist, isOpen,
-
{t('取消', 'Cancel')}
-
-
+
{isSaving ? t('保存中...', 'Saving...') : t('保存', 'Save')}
-
+
diff --git a/src/components/GistView.tsx b/src/components/GistView.tsx
index db941916..e0506d6f 100644
--- a/src/components/GistView.tsx
+++ b/src/components/GistView.tsx
@@ -1,5 +1,8 @@
+import { Input } from './ui/input';
+import { Button } from './ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import React, { useEffect, useMemo, useRef, useState } from 'react';
-import { Bot, ChevronDown, FileCode2, HelpCircle, Loader2, Plus, RefreshCw, Search, Star, User, X } from 'lucide-react';
+import { Bot, FileCode2, HelpCircle, Loader2, Plus, RefreshCw, Search, Star, User, X } from 'lucide-react';
import { GistCard } from './GistCard';
import { GistDetailModal } from './GistDetailModal';
import { GistEditorModal } from './GistEditorModal';
@@ -51,7 +54,6 @@ export const GistView: React.FC = () => {
const [isRefreshing, setIsRefreshing] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [isAnalyzingAll, setIsAnalyzingAll] = useState(false);
- const [sortOpen, setSortOpen] = useState(false);
const [detailGist, setDetailGist] = useState
(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [editingGist, setEditingGist] = useState(null);
@@ -265,19 +267,17 @@ export const GistView: React.FC = () => {
}
};
- const selectedSort = sortOptions.find(option => option.value === gistSearchFilters.sortBy) || sortOptions[0];
-
return (
-
-
+
+
-
Gist
+
Gist
-
-
-
+
+
+
{t('访问 Gist 需要 gist 权限', 'Gist access requires the gist scope')}
@@ -286,22 +286,23 @@ export const GistView: React.FC = () => {
'If your private gists are missing, or you cannot create/edit/delete gists, go to GitHub → Settings → Developer settings → Personal access tokens and make sure the gist scope is checked for your current token. Re-login with the updated token after changing scopes.'
)}
-
+
-
{categoryItems.all.length}
{categories.map(category => {
const Icon = categoryIcons[category.id];
const active = selectedGistCategory === category.id;
return (
- setSelectedGistCategory(category.id)}
- className={`linear-settings-nav-item flex w-full items-center justify-between px-3 py-2 text-sm ${
+ variant="ghost"
+ className={`linear-settings-nav-item group flex w-full items-center justify-between px-3 py-2 text-sm text-muted-foreground hover:text-accent-foreground ${
active ? 'is-active' : ''
}`}
>
@@ -309,45 +310,50 @@ export const GistView: React.FC = () => {
{t(category.name, category.nameEn)}
-
+
{categoryItems[category.id].length}
-
+
);
})}
-
+
-
-
setSortOpen(open => !open)}
- className="ui-button inline-flex items-center gap-2 px-3 py-2 text-sm"
- >
- {t(selectedSort.labelZh, selectedSort.labelEn)}
-
-
- {sortOpen && (
-
- {sortOptions.map(option => (
- {
- setGistSearchFilters({ sortBy: option.value });
- setSortOpen(false);
- }}
- className={`block w-full px-3 py-2 text-left text-sm ${
- gistSearchFilters.sortBy === option.value
- ? 'bg-brand-indigo/10 text-brand-indigo dark:text-white'
- : 'text-gray-700 hover:bg-light-surface dark:text-text-secondary dark:hover:bg-white/[0.08]'
- }`}
- >
- {t(option.labelZh, option.labelEn)}
-
- ))}
-
- )}
-
-
{
+ if (sortOptions.some((option) => option.value === value)) {
+ setGistSearchFilters({ sortBy: value as typeof sortOptions[number]['value'] });
+ }
+ }}
+ >
+
+
+
+
+ {sortOptions.map(option => (
+
+ {t(option.labelZh, option.labelEn)}
+
+ ))}
+
+
+ setGistSearchFilters({ sortOrder: gistSearchFilters.sortOrder === 'desc' ? 'asc' : 'desc' })}
className="ui-button px-3 py-2 text-sm"
>
{gistSearchFilters.sortOrder === 'desc' ? t('降序', 'Desc') : t('升序', 'Asc')}
-
-
+
{isAnalyzingAll ? : }
{t('AI分析', 'AI analyze')}
-
-
+
{t('同步', 'Sync')}
-
-
+ {
setEditingGist(null);
@@ -425,12 +422,12 @@ export const GistView: React.FC = () => {
>
{t('新建', 'New')}
-
+
-
+
{t(`共 ${gistSearchResults.length} 个 gist`, `${gistSearchResults.length} gists`)}
{gistSearchFilters.query && {t('已应用搜索', 'Search applied')} }
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 1d402bbc..d971762a 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -1,5 +1,8 @@
import React, { useState, useMemo } from 'react';
-import { Settings, Calendar, Search, Moon, Sun, LogOut, TrendingUp, GitFork, FileCode2 } from 'lucide-react';
+import { Settings, Calendar, Search, Moon, Sun, LogOut, TrendingUp, GitFork, FileCode2, Menu, X } from 'lucide-react';
+import { Button } from './ui/button';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu';
+import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useAppStore } from '../store/useAppStore';
import { useDialog } from '../hooks/useDialog';
import { HeaderMenuId, AppState } from '../types';
@@ -45,111 +48,110 @@ export const Header: React.FC = () => {
return (
-
+
{/* Logo and Title */}
-
+
-
+
GitHub Stars Manager
-
+
AI-powered repository management
-
+
GitHub Stars
{/* Navigation - Desktop & Tablet (≥768px) */}
-
+
{visibleMenus.map(menuItem => {
const meta = MENU_META[menuItem.id];
const Icon = meta.icon;
+ const label = t(meta.labelZh, meta.labelEn);
const isActive = currentView === menuItem.id;
return (
- setCurrentView(menuItem.id as AppState['currentView'])}
- title={t(meta.labelZh, meta.labelEn)}
- aria-label={t(meta.labelZh, meta.labelEn)}
- className={`linear-nav-item flex items-center whitespace-nowrap font-medium ${
- isActive ? 'is-active' : ''
- } xl:px-3 xl:py-1.5 p-2`}
+ aria-pressed={isActive}
+ title={label}
+ aria-label={label}
+ className="whitespace-nowrap xl:px-3"
>
-
- {t(meta.labelZh, meta.labelEn)}
-
+
+ {label}
+
);
})}
{/* Mobile Dropdown Menu (<768px) */}
- {mobileMenuOpen && (
-
-
- {visibleMenus.map(menuItem => {
- const meta = MENU_META[menuItem.id];
- const Icon = meta.icon;
- const isActive = currentView === menuItem.id;
- return (
- {
- setCurrentView(menuItem.id as AppState['currentView']);
- setMobileMenuOpen(false);
- }}
- className={`linear-nav-item flex items-center px-4 py-3 font-medium ${
- isActive ? 'is-active' : ''
- }`}
- >
-
- {t(meta.labelZh, meta.labelEn)}
-
- );
- })}
-
-
- )}
+
+
+
+ {mobileMenuOpen ? : }
+
+
+
+ {visibleMenus.map(menuItem => {
+ const meta = MENU_META[menuItem.id];
+ const Icon = meta.icon;
+ const isActive = currentView === menuItem.id;
+ return (
+ {
+ setCurrentView(menuItem.id as AppState['currentView']);
+ setMobileMenuOpen(false);
+ }}
+ aria-current={isActive ? 'page' : undefined}
+ className={isActive ? 'bg-muted dark:bg-accent' : undefined}
+ >
+
+ {t(meta.labelZh, meta.labelEn)}
+
+ );
+ })}
+
+
{/* User Actions */}
- {/* Mobile menu toggle */}
-
setMobileMenuOpen(!mobileMenuOpen)}
- className="linear-icon-button md:hidden p-2"
- aria-label={t('菜单', 'Menu')}
- >
-
- {mobileMenuOpen ? (
-
- ) : (
-
- )}
-
-
-
{/* Theme Toggle */}
-
setTheme(theme === 'light' ? 'dark' : 'light')}
- className="linear-icon-button p-2"
- title={t('切换主题', 'Toggle theme')}
- >
- {theme === 'light' ? (
-
- ) : (
-
- )}
-
+
+
+ setTheme(theme === 'light' ? 'dark' : 'light')}
+ aria-label={t('切换主题', 'Toggle theme')}
+ >
+ {theme === 'light' ? : }
+
+
+ {t('切换主题', 'Toggle theme')}
+
{/* User Profile */}
{user && (
@@ -160,28 +162,35 @@ export const Header: React.FC = () => {
className="w-8 h-8 rounded-full"
/>
-
+
{user.name || user.login}
-
{
- const confirmed = await confirm(
- t('退出登录确认', 'Logout Confirmation'),
- language === 'zh'
- ? '退出后您的 AI 配置、WebDAV 设置、自定义分类等数据仍会保留。如需完全清除所有数据,请前往「设置 → 数据管理」。'
- : 'Your AI configs, WebDAV settings, custom categories and other data will be preserved. To completely clear all data, please go to "Settings → Data Management".',
- { type: 'warning' }
- );
- if (confirmed) {
- logout();
- }
- }}
- className="linear-icon-button p-2"
- title={t('退出登录', 'Logout')}
- >
-
-
+
+
+ {
+ const confirmed = await confirm(
+ t('退出登录确认', 'Logout Confirmation'),
+ language === 'zh'
+ ? '退出后您的 AI 配置、WebDAV 设置、自定义分类等数据仍会保留。如需完全清除所有数据,请前往「设置 → 数据管理」。'
+ : 'Your AI configs, WebDAV settings, custom categories and other data will be preserved. To completely clear all data, please go to "Settings → Data Management".',
+ { type: 'warning' }
+ );
+ if (confirmed) {
+ logout();
+ }
+ }}
+ aria-label={t('退出登录', 'Logout')}
+ >
+
+
+
+ {t('退出登录', 'Logout')}
+
)}
diff --git a/src/components/ListsPushIndicator.tsx b/src/components/ListsPushIndicator.tsx
index 3941db6a..9af576ae 100644
--- a/src/components/ListsPushIndicator.tsx
+++ b/src/components/ListsPushIndicator.tsx
@@ -36,23 +36,23 @@ export const ListsPushIndicator: React.FC = () => {
: 0;
return (
-
+
-
+
{t('同步分类到 GitHub list', 'Pushing categories to lists')}
-
+
{listsPush.total > 0 ? `${listsPush.done}/${listsPush.total}` : '...'}
-
+
{listsPush.currentLabel && (
-
+
{listsPush.currentLabel}
)}
diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx
index f6579ba9..0f0d4d10 100644
--- a/src/components/LoginScreen.tsx
+++ b/src/components/LoginScreen.tsx
@@ -1,9 +1,14 @@
import React, { useState } from 'react';
-import { Github, Key, ArrowRight, AlertCircle, Moon, Sun } from 'lucide-react';
+import { AlertCircle, ArrowRight, Github, Key, Moon, Sun } from 'lucide-react';
import { useAppStore } from '../store/useAppStore';
import { GitHubApiService } from '../services/githubApi';
import { backend } from '../services/backendAdapter';
import { safeReadText } from '../utils/clipboardUtils';
+import { Button } from './ui/button';
+import { Card } from './ui/card';
+import { Input } from './ui/input';
+import { Label } from './ui/label';
+import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
export const LoginScreen: React.FC = () => {
const [token, setToken] = useState('');
@@ -21,11 +26,8 @@ export const LoginScreen: React.FC = () => {
setError('');
try {
- // Test the token by fetching user info
const githubApi = new GitHubApiService(token);
const user = await githubApi.getCurrentUser();
-
- // If successful, save the token and user info
setGitHubToken(token);
setUser(user);
@@ -44,8 +46,8 @@ export const LoginScreen: React.FC = () => {
} catch (error) {
console.error('Authentication failed:', error);
setError(
- error instanceof Error
- ? error.message
+ error instanceof Error
+ ? error.message
: (language === 'zh' ? '认证失败,请检查您的token。' : 'Failed to authenticate. Please check your token.')
);
} finally {
@@ -59,14 +61,12 @@ export const LoginScreen: React.FC = () => {
return;
}
- // 兼容桌面端首次登录场景下 Ctrl/Cmd + V 无法触发默认粘贴的问题
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v' && !isLoading) {
const result = await safeReadText();
if (result.success && result.text) {
setToken(result.text.trim());
setError('');
} else {
- // 读取剪贴板失败,让浏览器/系统默认行为继续兜底
console.warn('Clipboard read failed:', result.error);
}
}
@@ -75,152 +75,111 @@ export const LoginScreen: React.FC = () => {
const t = (zh: string, en: string) => language === 'zh' ? zh : en;
return (
-
- {/* Theme and Language Toggle */}
-
- {/* Language Toggle */}
-
-
setLanguage('zh')}
- className={`linear-login-language px-3 py-2 w-16 text-center text-sm font-medium ${language === 'zh' ? 'is-active' : ''}`}
- >
+
+
+
+ setLanguage('zh')} aria-pressed={language === 'zh'} className="w-16 rounded-none">
中文
-
- setLanguage('en')}
- className={`linear-login-language px-3 py-2 w-16 text-center text-sm font-medium ${language === 'en' ? 'is-active' : ''}`}
- >
+
+ setLanguage('en')} aria-pressed={language === 'en'} className="w-16 rounded-none">
EN
-
+
- {/* Theme Toggle */}
-
setTheme(theme === 'light' ? 'dark' : 'light')}
- className="linear-icon-button linear-login-theme p-2"
- title={t('切换主题', 'Toggle theme')}
- >
- {theme === 'light' ? (
-
- ) : (
-
- )}
-
+
+
+ setTheme(theme === 'light' ? 'dark' : 'light')} aria-label={t('切换主题', 'Toggle theme')}>
+ {theme === 'light' ? : }
+
+
+ {t('切换主题', 'Toggle theme')}
+
-
-
-
-
+
+
+
+
-
- GitHub Stars Manager
-
-
- {t('AI驱动的仓库管理工具', 'AI-powered repository management')}
-
+
GitHub Stars Manager
+
{t('AI驱动的仓库管理工具', 'AI-powered repository management')}
-
-
-
-
- {t('连接GitHub', 'Connect with GitHub')}
-
-
- {t('输入您的GitHub个人访问令牌以开始使用', 'Enter your GitHub personal access token to get started')}
-
+
+
+
+
{t('连接GitHub', 'Connect with GitHub')}
+
{t('输入您的GitHub个人访问令牌以开始使用', 'Enter your GitHub personal access token to get started')}
- {/* 显示缓存状态 */}
{repositories.length > 0 && lastSync && (
-
-
-
-
- {t(`已缓存 ${repositories.length} 个仓库`, `${repositories.length} repositories cached`)}
-
+
+
+
+
{t(`已缓存 ${repositories.length} 个仓库`, `${repositories.length} repositories cached`)}
-
- {t('上次同步:', 'Last sync:')} {new Date(lastSync).toLocaleString()}
-
+
{t('上次同步:', 'Last sync:')} {new Date(lastSync).toLocaleString()}
)}
-
-
- GitHub Personal Access Token
-
+
{error && (
-
-
-
{error}
+
)}
-
+
{isLoading ? (
<>
-
+
{t('连接中...', 'Connecting...')}
>
) : (
<>
{t('连接到GitHub', 'Connect to GitHub')}
-
+
>
)}
-
+
-
-
- {t('如何创建GitHub token:', 'How to create a GitHub token:')}
-
-
+
+
{t('如何创建GitHub token:', 'How to create a GitHub token:')}
+
1. {t('访问GitHub Settings → Developer settings → Personal access tokens', 'Go to GitHub Settings → Developer settings → Personal access tokens')}
2. {t('点击"Generate new token (classic)"', 'Click "Generate new token (classic)"')}
3. {t('选择权限范围:', 'Select scopes:')} repo 、user {t('和', 'and')} gist
4. {t('复制生成的token并粘贴到上方', 'Copy the generated token and paste it above')}
-
+
);
diff --git a/src/components/MarkdownRenderer.test.tsx b/src/components/MarkdownRenderer.test.tsx
index e7dfd3c8..b3dfc84e 100644
--- a/src/components/MarkdownRenderer.test.tsx
+++ b/src/components/MarkdownRenderer.test.tsx
@@ -172,14 +172,16 @@ describe('MarkdownRenderer', () => {
it('should show line numbers for code blocks with more than 3 lines', () => {
const content = '```javascript\nline1\nline2\nline3\nline4\n```';
const { container } = render(
);
- const lineNumbers = container.querySelectorAll('.text-gray-400');
- expect(lineNumbers.length).toBeGreaterThan(0);
+ const mutedElements = container.querySelectorAll('.text-muted-foreground');
+ const texts = Array.from(mutedElements).map((element) => element.textContent?.trim());
+ expect(texts).toContain('1');
+ expect(texts).toContain('4');
});
it('should not show line numbers for code blocks with 3 or fewer lines', () => {
const content = '```javascript\nline1\nline2\n```';
const { container } = render(
);
- const lineNumbers = container.querySelectorAll('.text-gray-400');
+ const lineNumbers = container.querySelectorAll('.text-muted-foreground');
const hasLineNumbers = Array.from(lineNumbers).some(el =>
el.textContent?.trim() === '1' || el.textContent?.trim() === '2'
);
diff --git a/src/components/MarkdownRenderer.tsx b/src/components/MarkdownRenderer.tsx
index 6b711d3f..b46a2977 100644
--- a/src/components/MarkdownRenderer.tsx
+++ b/src/components/MarkdownRenderer.tsx
@@ -1,3 +1,5 @@
+import { Input } from './ui/input';
+import { Button } from './ui/button';
import React, { memo, useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { createPortal } from 'react-dom';
import ReactMarkdown, { type Components } from 'react-markdown';
@@ -103,14 +105,14 @@ const CodeBlock: React.FC<{
return (
-
+
@@ -120,18 +122,18 @@ const CodeBlock: React.FC<{
{language && (
{isBashLike && (
-
+
)}
{isPowerShell && (
-
+
)}
{isCmdLike && (
@@ -141,14 +143,17 @@ const CodeBlock: React.FC<{
)}
-
@@ -165,11 +170,11 @@ const CodeBlock: React.FC<{
{uiLanguage === 'zh' ? '复制' : 'Copy'}
>
)}
-
+
{copyError && (
-
+
{copyError}
)}
@@ -180,20 +185,20 @@ const CodeBlock: React.FC<{
? 'bg-gradient-to-br from-blue-50/50 to-indigo-50/30 dark:from-[#0d1117] dark:to-[#161b22]'
: isCmdLike
? 'bg-gradient-to-br from-cyan-50/40 to-slate-100/20 dark:from-[#0d1117] dark:to-[#161b22]'
- : 'bg-light-bg dark:bg-[#0d1117]'
+ : 'bg-background dark:bg-[#0d1117]'
}`}>
{showLineNumbers && (
{codeLines.map((_, index) => (
-
+
{index + 1}
))}
)}
-
+
{codeText}
@@ -263,7 +268,7 @@ const MarkdownLink: React.FC<{ href?: string; children?: React.ReactNode; baseUr
href={resolvedHref}
target={isHashLink || isSpecialLink ? undefined : "_blank"}
rel={isHashLink || isSpecialLink ? undefined : "noopener noreferrer"}
- className="text-brand-violet dark:text-brand-violet hover:text-gray-700 dark:hover:text-text-secondary underline decoration-blue-400 hover:decoration-blue-600 transition-colors"
+ className="text-primary dark:text-primary hover:text-muted-foreground dark:hover:text-muted-foreground underline decoration-blue-400 hover:decoration-blue-600 transition-colors"
onClick={handleAnchorClick}
>
{children}
@@ -476,20 +481,21 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }>
if (hasError) {
return (
-
-
+
+
-
+
{language === 'zh' ? '图片加载失败' : 'Image failed'}
- {alt && {alt} }
- {alt} }
+
{language === 'zh' ? '重试' : 'Retry'}
-
+
);
}
@@ -499,7 +505,7 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }>
{isSmallImage ? (
{isLoading && (
-
+
)}
) : (
{isLoading && (
-
-
+
+
- {language === 'zh' ? '加载中...' : 'Loading...'}
+ {language === 'zh' ? '加载中...' : 'Loading...'}
)}
@@ -563,7 +569,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')
@@ -571,7 +577,7 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }>
}
{naturalWidth > 0 && (
- |
+ |
)}
{naturalWidth > 0 && (
{naturalWidth} × {naturalHeight}
@@ -582,7 +588,7 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }>
{!isLoading && !hasError && isInsideLink && parentLinkHref && (
{
e.stopPropagation();
e.preventDefault();
@@ -621,66 +627,84 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }>
{isInsideLink && parentLinkHref && (
-
{
e.stopPropagation();
window.open(parentLinkHref, '_blank', 'noopener,noreferrer');
}}
- className="p-2 bg-white/10 hover:bg-white/20 text-white/80 hover:text-white rounded-lg transition-colors backdrop-blur-sm"
+ className="h-8 w-8 p-0 bg-white/10 hover:bg-white/20 text-white/80 hover:text-white rounded-lg transition-colors backdrop-blur-sm"
title={language === 'zh' ? '打开链接' : 'Open link'}
>
-
+
)}
-
{
e.stopPropagation();
handleDownload(e);
}}
disabled={isDownloading}
- className="p-2 bg-white/10 hover:bg-white/20 text-white/80 hover:text-white rounded-lg transition-colors backdrop-blur-sm"
+ className="h-8 w-8 p-0 bg-white/10 hover:bg-white/20 text-white/80 hover:text-white rounded-lg transition-colors backdrop-blur-sm"
title={language === 'zh' ? '下载图片' : 'Download image'}
>
-
-
+ {
e.stopPropagation();
setZoomScale(prev => Math.min(5, prev + 0.5));
}}
- className="p-2 bg-white/10 hover:bg-white/20 text-white/80 hover:text-white rounded-lg transition-colors backdrop-blur-sm text-sm font-bold"
+ className="h-8 w-8 p-0 bg-white/10 hover:bg-white/20 text-white/80 hover:text-white rounded-lg transition-colors backdrop-blur-sm text-sm font-bold"
title={language === 'zh' ? '放大' : 'Zoom in'}
>
+
-
+
{Math.round(zoomScale * 100)}%
-
{
e.stopPropagation();
setZoomScale(prev => Math.max(0.5, prev - 0.5));
}}
- className="p-2 bg-white/10 hover:bg-white/20 text-white/80 hover:text-white rounded-lg transition-colors backdrop-blur-sm text-sm font-bold"
+ className="h-8 w-8 p-0 bg-white/10 hover:bg-white/20 text-white/80 hover:text-white rounded-lg transition-colors backdrop-blur-sm text-sm font-bold"
title={language === 'zh' ? '缩小' : 'Zoom out'}
>
−
-
-
+ {
e.stopPropagation();
setZoomScale(1);
setZoomPos({ x: 0, y: 0 });
}}
- className="p-2 bg-white/10 hover:bg-white/20 text-white/80 hover:text-white rounded-lg transition-colors backdrop-blur-sm text-xs"
+ className="h-8 w-8 p-0 bg-white/10 hover:bg-white/20 text-white/80 hover:text-white rounded-lg transition-colors backdrop-blur-sm text-xs"
title={language === 'zh' ? '重置' : 'Reset'}
>
1:1
-
-
+ {
e.stopPropagation();
closeZoom();
@@ -690,7 +714,7 @@ const MarkdownImage: React.FC<{ src?: string; alt?: string; baseUrl?: string }>
-
+
@@ -813,27 +837,27 @@ const MarkdownRenderer: React.FC
= memo(({
img: (props) => ,
h1: ({ children }) => {
const id = getHeadingId(children);
- return {children} ;
+ return {children} ;
},
h2: ({ children }) => {
const id = getHeadingId(children);
- return {children} ;
+ return {children} ;
},
h3: ({ children }) => {
const id = getHeadingId(children);
- return {children} ;
+ return {children} ;
},
h4: ({ children }) => {
const id = getHeadingId(children);
- return {children} ;
+ return {children} ;
},
h5: ({ children }) => {
const id = getHeadingId(children);
- return {children} ;
+ return {children} ;
},
h6: ({ children }) => {
const id = getHeadingId(children);
- return {children} ;
+ return {children} ;
},
p: ({ children }) => {
const childArray = React.Children.toArray(children);
@@ -849,7 +873,7 @@ const MarkdownRenderer: React.FC = memo(({
);
return (
- = memo(({
);
},
- ul: ({ children }) => ,
- ol: ({ children }) => {children} ,
+ ul: ({ children }) => ,
+ ol: ({ children }) => {children} ,
li: ({ children, className, ...props }) => (
{children}
),
strong: ({ children }) => (
- {children}
+ {children}
),
em: ({ children }) => (
- {children}
+ {children}
),
del: ({ children }) => (
- {children}
+ {children}
),
code: ({ className, children, ...props }) => {
// 检查 props 中是否有 'data-code-block' 标记(由 pre 组件添加)
@@ -882,7 +906,7 @@ const MarkdownRenderer: React.FC = memo(({
const language = match ? match[1] : '';
return isInline ? (
-
+
{children}
) : (
@@ -901,27 +925,27 @@ const MarkdownRenderer: React.FC = memo(({
return {children} ;
},
blockquote: ({ children }) => (
-
+
{children}
),
- hr: () => ,
+ hr: () => ,
table: ({ children }) => (
-
+
),
- thead: ({ children }) => {children} ,
- tbody: ({ children }) => {children} ,
+ thead: ({ children }) => {children} ,
+ tbody: ({ children }) => {children} ,
th: ({ children }) => (
-
+
{children}
),
td: ({ children }) => (
-
+
{children}
),
@@ -931,16 +955,16 @@ const MarkdownRenderer: React.FC = memo(({
);
}
- return ;
+ return ;
},
}), [baseUrl, headingIds, getHeadingId]);
if (!shouldRender) {
- return Loading...
;
+ return Loading...
;
}
return (
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
index e4353d19..d41408a9 100644
--- a/src/components/Modal.tsx
+++ b/src/components/Modal.tsx
@@ -1,5 +1,10 @@
-import React, { useEffect } from 'react';
-import { X } from 'lucide-react';
+import * as React from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from './ui/dialog';
interface ModalProps {
isOpen: boolean;
@@ -14,69 +19,14 @@ export const Modal: React.FC = ({
onClose,
title,
children,
- maxWidth = 'max-w-md'
-}) => {
- // Close modal on Escape key press
- useEffect(() => {
- const handleEscape = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- onClose();
- }
- };
-
- if (isOpen) {
- document.addEventListener('keydown', handleEscape);
- // Prevent body scroll when modal is open
- document.body.style.overflow = 'hidden';
- }
-
- return () => {
- document.removeEventListener('keydown', handleEscape);
- document.body.style.overflow = 'unset';
- };
- }, [isOpen, onClose]);
-
- if (!isOpen) return null;
-
- return (
- e.stopPropagation()}
- >
- {/* Backdrop */}
-
{
- e.stopPropagation();
- onClose();
- }}
- />
-
- {/* Modal */}
-
-
e.stopPropagation()}
- >
- {/* Header */}
-
-
- {title}
-
-
-
-
-
-
- {/* Content */}
-
- {children}
-
-
-
-
- );
-};
\ No newline at end of file
+ maxWidth = 'max-w-md',
+}) => (
+
!open && onClose()}>
+
+
+ {title}
+
+ {children}
+
+
+);
diff --git a/src/components/ReadmeModal.test.tsx b/src/components/ReadmeModal.test.tsx
index 7b3f064e..7a93b86f 100644
--- a/src/components/ReadmeModal.test.tsx
+++ b/src/components/ReadmeModal.test.tsx
@@ -1,4 +1,5 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ReadmeModal } from './ReadmeModal';
import { backend } from '../services/backendAdapter';
@@ -99,9 +100,11 @@ describe('ReadmeModal multilingual README switching', () => {
expect(await screen.findByText('Default README content')).toBeInTheDocument();
expect(backend.getRepositoryReadme).toHaveBeenCalledWith('owner', 'demo', expect.any(AbortSignal));
- const selector = await screen.findByLabelText('切换 README 语言');
+ const user = userEvent.setup();
+ const selector = await screen.findByRole('combobox', { name: '切换 README 语言' });
expect(selector).toBeInTheDocument();
- expect(screen.getByRole('option', { name: '默认 README' })).toBeInTheDocument();
+ await user.click(selector);
+ expect(await screen.findByRole('option', { name: '默认 README' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: '中文 · README_zh.md' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: '日语 · docs/README.ja.md' })).toBeInTheDocument();
});
@@ -110,9 +113,11 @@ describe('ReadmeModal multilingual README switching', () => {
render(
);
await screen.findByText('Default README content');
- const selector = await screen.findByLabelText('切换 README 语言');
+ const user = userEvent.setup();
+ const selector = await screen.findByRole('combobox', { name: '切换 README 语言' });
- fireEvent.change(selector, { target: { value: 'README_zh.md' } });
+ await user.click(selector);
+ await user.click(await screen.findByRole('option', { name: '中文 · README_zh.md' }));
await waitFor(() => {
expect(backend.getRepositoryReadmeByPath).toHaveBeenCalledWith('owner', 'demo', 'README_zh.md', expect.any(AbortSignal));
@@ -186,8 +191,10 @@ describe('ReadmeModal multilingual README switching', () => {
render(
);
await screen.findByText('Default README content');
- const selector = await screen.findByLabelText('切换 README 语言');
- fireEvent.change(selector, { target: { value: 'README_zh.md' } });
+ const user = userEvent.setup();
+ const selector = await screen.findByRole('combobox', { name: '切换 README 语言' });
+ await user.click(selector);
+ await user.click(await screen.findByRole('option', { name: '中文 · README_zh.md' }));
expect(await screen.findByText('Direct 中文 README 内容')).toBeInTheDocument();
expect(mockGitHubApi.getRepositoryReadmeByPath).toHaveBeenCalledWith('owner', 'demo', 'README_zh.md', expect.any(AbortSignal));
@@ -209,9 +216,11 @@ describe('ReadmeModal multilingual README switching', () => {
render(
);
expect(await screen.findByText('Default README content')).toBeInTheDocument();
- const selector = await screen.findByLabelText('切换 README 语言');
+ const user = userEvent.setup();
+ const selector = await screen.findByRole('combobox', { name: '切换 README 语言' });
expect(selector).toBeInTheDocument();
- expect(screen.getByRole('option', { name: '中文 · README_zh.md' })).toBeInTheDocument();
+ await user.click(selector);
+ expect(await screen.findByRole('option', { name: '中文 · README_zh.md' })).toBeInTheDocument();
expect(mockGitHubApi.listRepositoryReadmeCandidates).toHaveBeenCalledWith('owner', 'demo', undefined, expect.any(AbortSignal));
} finally {
consoleWarnSpy.mockRestore();
diff --git a/src/components/ReadmeModal.tsx b/src/components/ReadmeModal.tsx
index ca7737f4..387e3728 100644
--- a/src/components/ReadmeModal.tsx
+++ b/src/components/ReadmeModal.tsx
@@ -1,3 +1,5 @@
+import { Button } from './ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { X, Loader2, AlertCircle, FileText, ExternalLink, List, Type, ArrowUp, Languages, Eye } from 'lucide-react';
import BilingualMarkdownRenderer, { DisplayMode, BilingualMarkdownRendererHandle, TranslationStatus } from './BilingualMarkdownRenderer';
@@ -7,6 +9,7 @@ import { GitHubApiService } from '../services/githubApi';
import { backend } from '../services/backendAdapter';
import { useAppStore } from '../store/useAppStore';
import { buildReadmeVariants, DEFAULT_README_VARIANT, type GitHubReadmeCandidateItem, type ReadmeVariant } from '../utils/readmeVariants';
+import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
interface TocItem {
id: string;
@@ -64,9 +67,7 @@ export const ReadmeModal: React.FC
= ({
const defaultReadmeVariant = useMemo(() => getDefaultReadmeVariant(language), [language]);
- const modalRef = useRef(null);
const contentRef = useRef(null);
- const previousFocusRef = useRef(null);
const abortControllerRef = useRef(null);
const variantsAbortControllerRef = useRef(null);
const isResizingRef = useRef(false);
@@ -461,8 +462,7 @@ export const ReadmeModal: React.FC = ({
await fetchReadmeContent(currentVariant);
}, [readmeVariants, selectedReadmeKey, defaultReadmeVariant, fetchReadmeContent]);
- const handleReadmeVariantChange = useCallback((event: React.ChangeEvent) => {
- const nextKey = event.target.value;
+ const handleReadmeVariantChange = useCallback((nextKey: string) => {
if (nextKey === selectedReadmeKey) return;
const nextVariant = readmeVariants.find(variant => variant.key === nextKey);
@@ -561,38 +561,7 @@ export const ReadmeModal: React.FC = ({
};
}, []);
- useEffect(() => {
- const handleEscape = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- onClose();
- }
- };
-
- if (isOpen) {
- previousFocusRef.current = document.activeElement as HTMLElement;
- document.addEventListener('keydown', handleEscape);
- document.body.style.overflow = 'hidden';
- setTimeout(() => {
- modalRef.current?.focus();
- }, 0);
- }
-
- return () => {
- document.removeEventListener('keydown', handleEscape);
- if (document.body.style.overflow === 'hidden') {
- document.body.style.overflow = 'unset';
- }
- previousFocusRef.current?.focus();
- };
- }, [isOpen, onClose]);
-
- if (!isOpen || !repository) return null;
-
- const handleBackdropClick = (event: React.MouseEvent) => {
- if (event.target === event.currentTarget) {
- onClose();
- }
- };
+ if (!repository) return null;
const tocIndentClass = (level: number): string => {
switch (level) {
@@ -607,9 +576,9 @@ export const ReadmeModal: React.FC = ({
};
const tocTextClass = (level: number): string => {
- if (level <= 2) return 'font-medium text-gray-800 dark:text-gray-200';
- if (level <= 4) return 'text-gray-600 dark:text-gray-400';
- return 'text-gray-500 dark:text-gray-500 text-xs';
+ if (level <= 2) return 'font-medium text-foreground dark:text-muted-foreground';
+ if (level <= 4) return 'text-muted-foreground dark:text-muted-foreground';
+ return 'text-muted-foreground dark:text-muted-foreground text-xs';
};
const isTranslating = translateStatus === 'translating';
@@ -618,23 +587,15 @@ export const ReadmeModal: React.FC = ({
const currentReadmeVariant = readmeVariants.find(variant => variant.key === selectedReadmeKey) || defaultReadmeVariant;
return (
-
-
!open && onClose()}>
+
- e.stopPropagation()}
- >
+
{readmeContent && !loading && (
-
+
= ({
)}
-
-
+
+
-
+
{repository.full_name}
-
-
+
+
{currentReadmeVariant.isDefault ? 'README' : currentReadmeVariant.path}
-
+
{readmeVariants.length > 1 && (
-
- {readmeVariants.map((variant) => (
-
- {variant.label}
-
- ))}
-
+
+
+ {readmeVariants.map((variant) => {variant.label} )}
+
)}
{readmeContent && !loading && (
isTranslated ? (
<>
-
{t('已翻译', 'Translated')}
-
+
{([
{ 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 }) => (
- setDisplayMode(mode)}
className={`flex items-center space-x-1 px-2 py-2 text-sm rounded-lg transition-colors ${
displayMode === mode
- ? 'bg-brand-indigo/20 text-brand-violet dark:bg-brand-indigo/10 dark:text-brand-violet'
- : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-light-surface dark:hover:bg-white/5'
+ ? 'bg-primary/20 text-primary dark:bg-primary/10 dark:text-primary'
+ : 'text-muted-foreground hover:text-muted-foreground dark:hover:text-muted-foreground hover:bg-muted dark:hover:bg-card'
}`}
title={label}
>
{label}
-
+
))}
>
) : isTranslateError ? (
<>
-
{t('重试', 'Retry')}
-
-
+
-
+
>
) : (
-
@@ -750,7 +706,7 @@ export const ReadmeModal: React.FC = ({
{language === 'zh' ? t('翻译为中文', 'Translate to Chinese') : t('翻译为英文', 'Translate to English')}
>
)}
-
+
)
)}
{translateError && (
@@ -763,71 +719,79 @@ export const ReadmeModal: React.FC = ({
)}
{tocItems.length > 0 && (
-
setShowToc(!showToc)}
- className={`p-2 rounded-lg transition-colors ${
+ aria-label={t('目录', 'Table of Contents')}
+ className={`h-8 w-8 rounded-lg p-0 transition-colors ${
showToc
- ? 'bg-brand-indigo/20 text-brand-violet dark:bg-brand-indigo/10 dark:text-brand-violet'
- : 'text-gray-400 hover:text-gray-700 dark:hover:text-gray-900 hover:bg-light-surface dark:hover:bg-white/10'
+ ? 'bg-primary/20 text-primary dark:bg-primary/10 dark:text-primary'
+ : 'text-muted-foreground hover:text-muted-foreground dark:hover:text-foreground hover:bg-muted dark:hover:bg-accent'
}`}
title={t('目录', 'Table of Contents')}
>
-
+
)}
-
-
+
{t('在 GitHub 上查看', 'View on GitHub')}
-
-
+
-
+
{showToc && tocItems.length > 0 && (
<>
-
+
{t('目录', 'Contents')}
{tocItems.map((item) => {
const displayText = translatedHeadingMap.get(item.id) || item.text;
return (
- scrollToHeading(item.id, item.text)}
- className={`block w-full text-left text-sm py-1 px-2 rounded transition-colors truncate ${tocIndentClass(item.level)} ${tocTextClass(item.level)} ${
+ className={`h-auto block w-full text-left text-sm py-1 px-2 rounded transition-colors truncate ${tocIndentClass(item.level)} ${tocTextClass(item.level)} ${
activeHeadingId === item.id
- ? 'bg-brand-indigo/10 text-brand-violet dark:bg-brand-indigo/10 dark:text-brand-violet font-medium'
- : 'hover:bg-light-surface dark:hover:bg-white/5'
+ ? 'bg-primary/10 text-primary dark:bg-primary/10 dark:text-primary font-medium'
+ : 'hover:bg-muted dark:hover:bg-card'
}`}
title={displayText}
>
{displayText}
-
+
);
})}
@@ -843,28 +807,28 @@ export const ReadmeModal: React.FC = ({
{loading ? (
-
-
+
+
{language === 'zh' ? '正在加载 README...' : 'Loading README...'}
) : error ? (
-
-
+
+
{error}
-
{language === 'zh' ? '重试' : 'Retry'}
-
+
) : readmeContent ? (
= ({
/>
) : (
-
-
+
+
{language === 'zh' ? '该仓库没有 README 文件' : 'This repository has no README file'}
@@ -891,17 +855,18 @@ export const ReadmeModal: React.FC = ({
{showBackToTop && (
-
-
+
)}
-
-
+
+
);
};
diff --git a/src/components/ReleaseCard.tsx b/src/components/ReleaseCard.tsx
index ff082b6d..3a50382c 100644
--- a/src/components/ReleaseCard.tsx
+++ b/src/components/ReleaseCard.tsx
@@ -11,6 +11,7 @@ import {
effectiveReleaseTime,
shouldShowAssetsUpdatedIndicator,
} from '../utils/releaseAssets';
+import { Button } from './ui/button';
type SummaryState = {
status: 'idle' | 'loading' | 'done' | 'error';
@@ -200,38 +201,38 @@ const ReleaseCard: React.FC
= memo(({
{isUnread && (
-
+
)}
-
+
-
+
{release.repository.name}
{release.tag_name}
{release.name && release.name !== release.tag_name && (
-
+
{release.name}
)}
-
+
{release.repository.full_name}
-
+
{formatDistanceToNow(new Date(effectiveTime), { addSuffix: true })}
{showAssetsUpdatedIndicator && (
-
+
{t('资产已更新', 'Assets updated')}
)}
@@ -249,16 +250,13 @@ const ReleaseCard: React.FC = memo(({
{downloadLinks.length > 0 && (
-
{
e.stopPropagation();
onToggleAssets();
}}
- className={`flex items-center space-x-0.5 px-1.5 py-1 rounded transition-all duration-200 whitespace-nowrap ${
- isAssetsExpanded
- ? 'bg-brand-indigo/15 text-brand-indigo dark:bg-brand-indigo/20 dark:text-white'
- : 'bg-light-surface text-gray-700 dark:bg-white/[0.04] dark:text-text-tertiary hover:bg-gray-200 dark:hover:bg-white/[0.08]'
- }`}
+ variant={isAssetsExpanded ? 'secondary' : 'ghost'}
+ className="h-8 gap-1 px-2 text-xs whitespace-nowrap"
title={isAssetsExpanded ? t('隐藏下载资产', 'Hide Assets') : t('显示下载资产', 'Show Assets')}
aria-label={isAssetsExpanded ? t('隐藏下载资产', 'Hide Assets') : t('显示下载资产', 'Show Assets')}
aria-expanded={isAssetsExpanded}
@@ -266,20 +264,17 @@ const ReleaseCard: React.FC = memo(({
{isAssetsExpanded ? : }
{isAssetsExpanded ? t('隐藏', 'Hide') : t('资产', 'Assets')}
{isAssetsExpanded ? : }
-
+
)}
{release.body && (
-
{
e.stopPropagation();
onToggleReleaseNotes();
}}
- className={`flex items-center space-x-0.5 px-1.5 py-1 rounded transition-all duration-200 whitespace-nowrap ${
- isReleaseNotesExpanded
- ? 'bg-gray-100 dark:bg-white/[0.08] text-gray-700 dark:text-text-secondary '
- : 'bg-light-surface text-gray-700 dark:bg-white/[0.04] dark:text-text-tertiary hover:bg-gray-200 dark:hover:bg-white/[0.08]'
- }`}
+ variant={isReleaseNotesExpanded ? 'secondary' : 'ghost'}
+ className="h-8 gap-1 px-2 text-xs whitespace-nowrap"
title={isReleaseNotesExpanded ? t('隐藏更新日志', 'Hide Changelog') : t('显示更新日志', 'Show Changelog')}
aria-label={isReleaseNotesExpanded ? t('隐藏更新日志', 'Hide Changelog') : t('显示更新日志', 'Show Changelog')}
aria-expanded={isReleaseNotesExpanded}
@@ -287,18 +282,15 @@ const ReleaseCard: React.FC = memo(({
{isReleaseNotesExpanded ? t('隐藏', 'Hide') : t('日志', 'Notes')}
{isReleaseNotesExpanded ? : }
-
+
)}
{release.body?.trim() && (
-
= memo(({
)}
{t('总结', 'Summary')}
{summary.status !== 'loading' && (isSummaryExpanded ? : )}
-
+
)}
-
{
e.stopPropagation();
onUnsubscribe();
}}
- className="p-1 rounded bg-light-surface text-gray-700 dark:bg-white/[0.04] dark:text-text-secondary hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-white/[0.08] dark:hover:text-text-primary transition-colors"
+ className="h-auto p-1 rounded bg-muted text-muted-foreground dark:bg-muted/40 dark:text-muted-foreground hover:bg-accent hover:text-foreground dark:hover:bg-accent dark:hover:text-foreground transition-colors"
title={t('取消订阅 Release', 'Unsubscribe from releases')}
aria-label={t('取消订阅 Release', 'Unsubscribe from releases')}
>
-
+
{
@@ -349,20 +341,20 @@ const ReleaseCard: React.FC = memo(({
style={{ gridTemplateRows: (isAssetsExpanded || isReleaseNotesExpanded || isSummaryExpanded) ? '1fr' : '0fr' }}
>
-
+
{isAssetsExpanded && downloadLinks.length > 0 && (
-
-
+
+
{t('下载文件', 'Download Files')}
-
+
({downloadLinks.length})
-
+
{downloadLinks.map((link, index) => {
const isRpcEnabled = rpcDownloadConfig.enabled;
const isDownloading = downloadingRef.current[link.url];
@@ -372,35 +364,36 @@ const ReleaseCard: React.FC
= memo(({
if (isRpcEnabled) {
return (
- {
e.stopPropagation();
if (link.assetId !== undefined) onMarkAssetAsRead(link.assetId);
handleRpcDownload(link);
}}
disabled={isDownloading || isDownloaded}
- className={`flex items-center justify-between px-4 py-3 w-full text-left hover:bg-light-surface dark:hover:bg-white/[0.06] transition-colors border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0 disabled:opacity-60 ${
- link.isSourceCode ? 'bg-gray-100 dark:bg-white/[0.04]' : ''
+ className={`h-auto flex items-center justify-between px-4 py-3 w-full text-left hover:bg-muted dark:hover:bg-accent transition-colors border-b border-border last:border-b-0 disabled:opacity-60 ${
+ link.isSourceCode ? 'bg-accent/60' : ''
}`}
>
{isDownloaded ? (
) : isDownloading ? (
-
+
) : link.isSourceCode ? (
-
+
) : (
-
+
)}
-
+
{link.name}
-
+
{isAssetUpdated && (
-
+
{t('资产已更新', 'Asset updated')}
)}
@@ -411,7 +404,7 @@ const ReleaseCard: React.FC = memo(({
{link.downloadCount.toLocaleString()} {t('下载', 'downloads')}
)}
-
+
);
}
@@ -421,8 +414,8 @@ const ReleaseCard: React.FC
= memo(({
href={link.url}
target="_blank"
rel="noopener noreferrer"
- className={`flex items-center justify-between px-4 py-3 hover:bg-light-surface dark:hover:bg-white/[0.06] transition-colors border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0 ${
- link.isSourceCode ? 'bg-gray-100 dark:bg-white/[0.04]' : ''
+ className={`flex items-center justify-between px-4 py-3 hover:bg-muted dark:hover:bg-accent transition-colors border-b border-border last:border-b-0 ${
+ link.isSourceCode ? 'bg-accent/60' : ''
}`}
onClick={(e) => {
e.stopPropagation();
@@ -431,17 +424,17 @@ const ReleaseCard: React.FC = memo(({
>
{link.isSourceCode ? (
-
+
) : (
-
+
)}
-
+
{link.name}
-
+
{isAssetUpdated && (
-
+