feat: 全面重构网站工程化体系与 UI 架构
- 将单体 style.css 拆分为 tokens/reset/fonts/layout/responsive/组件级 CSS 模块 - 从 Google Fonts CDN 迁移至本地自托管字体(JetBrainsMono、NotoSansSC) - 引入 Vitest + Testing Library 测试体系,新增单元测试 - 添加 GitHub Actions CI 流水线(lint → build → test) - 新增 Prettier 格式化与 ESLint 规则强化 - 重构全部 YAML 数据文件,完善项目详情页(截图轮播、更新日志) - 新增项目文档编写指南(docs/create-project.md)
This commit is contained in:
+19
-62
@@ -1,40 +1,7 @@
|
||||
import { Bug, ExternalLink, MessageCircle, Shield } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ExternalLink, Mail } from 'lucide-react';
|
||||
import { useI18n } from '../hooks/useI18n';
|
||||
import { siteData } from '../data/siteData';
|
||||
|
||||
interface ContactCard {
|
||||
icon: LucideIcon;
|
||||
titleKey: string;
|
||||
descKey: string;
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const contactCards: ContactCard[] = [
|
||||
{
|
||||
icon: Bug,
|
||||
titleKey: 'contact.issues',
|
||||
descKey: 'contact.issues.desc',
|
||||
href: `${siteData.brand.github}/issues`,
|
||||
label: 'GitHub Issues',
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
titleKey: 'contact.discussions',
|
||||
descKey: 'contact.discussions.desc',
|
||||
href: `${siteData.brand.github}/discussions`,
|
||||
label: 'GitHub Discussions',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
titleKey: 'contact.security',
|
||||
descKey: 'contact.security.desc',
|
||||
href: `${siteData.brand.github}/security`,
|
||||
label: 'Security',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AboutPage() {
|
||||
const { t, bi, biArray } = useI18n();
|
||||
const { about, brand } = siteData;
|
||||
@@ -47,7 +14,9 @@ export default function AboutPage() {
|
||||
</div>
|
||||
|
||||
<div className="about-header">
|
||||
<div className="about-avatar">SZ</div>
|
||||
<div className="about-avatar">
|
||||
<img src="/avatar.jpg" alt={brand.author} className="about-avatar-img" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="about-name">{brand.author}</div>
|
||||
<div className="about-bio">{bi(about.bio)}</div>
|
||||
@@ -60,7 +29,9 @@ export default function AboutPage() {
|
||||
</div>
|
||||
<div className="focus-grid">
|
||||
{biArray(about.focus).map((item) => (
|
||||
<div key={item} className="focus-item">{item}</div>
|
||||
<div key={item} className="focus-item">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,7 +42,9 @@ export default function AboutPage() {
|
||||
</div>
|
||||
<div className="techstack-grid">
|
||||
{about.techStack.map((tech) => (
|
||||
<span key={tech} className="tech-tag">{tech}</span>
|
||||
<span key={tech} className="tech-tag">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,31 +61,15 @@ export default function AboutPage() {
|
||||
<h2 className="section-title">{t('contact.title')}</h2>
|
||||
</div>
|
||||
<div className="contact-grid">
|
||||
{contactCards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<a
|
||||
key={card.titleKey}
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="contact-card"
|
||||
>
|
||||
<h3>
|
||||
<Icon size={18} />
|
||||
{t(card.titleKey)}
|
||||
</h3>
|
||||
<p>{t(card.descKey)}</p>
|
||||
<span className="contact-card-link">{card.label}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<a
|
||||
href={about.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="contact-card"
|
||||
>
|
||||
<a href={`mailto:${brand.email}`} className="contact-card">
|
||||
<h3>
|
||||
<Mail size={18} />
|
||||
{t('contact.email')}
|
||||
</h3>
|
||||
<p>{t('contact.email.desc')}</p>
|
||||
<span className="contact-card-link">{brand.email}</span>
|
||||
</a>
|
||||
<a href={about.github} target="_blank" rel="noopener noreferrer" className="contact-card">
|
||||
<h3>
|
||||
<ExternalLink size={18} />
|
||||
{t('about.github')}
|
||||
|
||||
+16
-5
@@ -1,3 +1,4 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useI18n } from '../hooks/useI18n';
|
||||
import { siteData } from '../data/siteData';
|
||||
@@ -9,7 +10,9 @@ import FeaturedCard from '../components/FeaturedCard';
|
||||
export default function HomePage() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const featuredProjects = siteData.projects.filter((p) => p.featured);
|
||||
const featuredProjects = siteData.projects
|
||||
.filter((p) => p.featured)
|
||||
.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
@@ -18,7 +21,15 @@ export default function HomePage() {
|
||||
<StatsBar />
|
||||
|
||||
<div className="section">
|
||||
<SectionHeader title={t('featured.title')} subtitle={t('featured.subtitle')} />
|
||||
<SectionHeader
|
||||
title={t('featured.title')}
|
||||
subtitle={t('featured.subtitle')}
|
||||
action={
|
||||
<Link to="/projects" className="section-action">
|
||||
{t('featured.viewAll')} →
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div className="card-grid">
|
||||
{featuredProjects.map((p) => (
|
||||
<FeaturedCard key={p.id} project={p} />
|
||||
@@ -26,12 +37,12 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section section-compact" style={{ textAlign: 'center' }}>
|
||||
<div className="section section-compact cta-section">
|
||||
<h2 className="section-title">{t('cta.title')}</h2>
|
||||
<p className="section-subtitle" style={{ margin: '8px auto 0', maxWidth: 600 }}>
|
||||
<p className="section-subtitle cta-subtitle">
|
||||
{t('cta.subtitle')}
|
||||
</p>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div className="cta-actions">
|
||||
<a
|
||||
href={siteData.brand.github}
|
||||
target="_blank"
|
||||
|
||||
@@ -4,43 +4,47 @@ import { siteData } from '../data/siteData';
|
||||
import DownloadTable from '../components/DownloadTable';
|
||||
import RoadmapGrid from '../components/RoadmapGrid';
|
||||
import ChangelogList from '../components/ChangelogList';
|
||||
import { NotebookPen, Terminal, MapPin, Smartphone, BookOpen, Monitor, Brain, KeyRound, ExternalLink, Download } from 'lucide-react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
const iconMap: Record<string, ComponentType<{ size?: number }>> = {
|
||||
NotebookPen, Terminal, MapPin, Smartphone, BookOpen, Monitor, Brain, KeyRound,
|
||||
};
|
||||
import ScreenshotCarousel from '../components/ScreenshotCarousel';
|
||||
import { getIcon } from '../utils/iconRegistry';
|
||||
import { ExternalLink, Download, BookOpen } from 'lucide-react';
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams();
|
||||
const { t, bi, biArray, lang } = useI18n();
|
||||
const p = siteData.projects.find(pr => pr.id === id);
|
||||
const p = siteData.projects.find((pr) => pr.id === id);
|
||||
|
||||
if (!p) {
|
||||
return <div className="container"><div className="empty-state">Project not found</div></div>;
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="empty-state">Project not found</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusDef = siteData.statuses[p.status];
|
||||
const hasDownloads = p.downloads && p.downloads.length > 0;
|
||||
const IconComponent = iconMap[p.icon];
|
||||
const IconComponent = getIcon(p.icon);
|
||||
|
||||
return (
|
||||
<div className="container fade-in">
|
||||
{/* Breadcrumb */}
|
||||
<div style={{ padding: '8px 0', fontSize: 13, color: 'var(--muted)' }}>
|
||||
<Link to="/" style={{ color: 'var(--muted)' }}>{bi(siteData.nav[0].label)}</Link>
|
||||
<div className="breadcrumb">
|
||||
<Link to="/">
|
||||
{bi(siteData.nav[0].label)}
|
||||
</Link>
|
||||
{' / '}
|
||||
<Link to="/projects" style={{ color: 'var(--muted)' }}>{bi(siteData.nav[1].label)}</Link>
|
||||
<Link to="/projects">
|
||||
{bi(siteData.nav[1].label)}
|
||||
</Link>
|
||||
{' / '}
|
||||
<span style={{ color: 'var(--fg)' }}>{p.name}</span>
|
||||
<span className="breadcrumb-current">{p.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="detail-header">
|
||||
<div className="detail-header-top">
|
||||
<div className="detail-icon">
|
||||
{IconComponent ? <IconComponent size={28} /> : <span>{p.icon}</span>}
|
||||
{IconComponent ? <IconComponent size={28} /> : <span>{p.icon}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="detail-title">{p.displayName[lang] || p.name}</h1>
|
||||
@@ -48,15 +52,47 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-badges">
|
||||
{p.techStack.map(ts => <span key={ts} className="badge badge-accent">{ts}</span>)}
|
||||
{p.platforms.map(pl => <span key={pl} className="badge">{pl}</span>)}
|
||||
<span className="badge badge-status" style={{ background: statusDef?.color || '#6B6B6B' }}>{bi(statusDef?.label)}</span>
|
||||
{p.techStack.map((ts) => (
|
||||
<span key={ts} className="badge badge-accent">
|
||||
{ts}
|
||||
</span>
|
||||
))}
|
||||
{p.platforms.map((pl) => (
|
||||
<span key={pl} className="badge">
|
||||
{pl}
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className="badge badge-status"
|
||||
style={{ background: statusDef?.color || '#6B6B6B' }}
|
||||
>
|
||||
{bi(statusDef?.label)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-actions">
|
||||
<a href={p.repoUrl} target="_blank" className="btn btn-primary"><ExternalLink size={16} />GitHub</a>
|
||||
{hasDownloads && <Link to="/downloads" className="btn"><Download size={16} />{t('common.download')}</Link>}
|
||||
{p.docsUrl && <a href={p.docsUrl} className="btn"><BookOpen size={16} />{t('common.docs')}</a>}
|
||||
<a href={`${p.repoUrl}/issues`} target="_blank" className="btn btn-ghost"><ExternalLink size={16} />{t('contact.issues')}</a>
|
||||
<a href={p.repoUrl} target="_blank" className="btn btn-primary">
|
||||
<ExternalLink size={16} />
|
||||
GitHub
|
||||
</a>
|
||||
{hasDownloads && (
|
||||
<a href="#downloads" className="btn" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById('downloads')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}>
|
||||
<Download size={16} />
|
||||
{t('common.download')}
|
||||
</a>
|
||||
)}
|
||||
{p.docsUrl && (
|
||||
<a href={p.docsUrl} target="_blank" rel="noopener noreferrer" className="btn">
|
||||
<BookOpen size={16} />
|
||||
{t('common.docs')}
|
||||
</a>
|
||||
)}
|
||||
<a href={`${p.repoUrl}/issues`} target="_blank" className="btn btn-ghost">
|
||||
<ExternalLink size={16} />
|
||||
{t('contact.issues')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,26 +109,31 @@ export default function ProjectDetailPage() {
|
||||
<div className="detail-section">
|
||||
<h2 className="detail-section-title">{t('detail.features')}</h2>
|
||||
<div className="feature-tags">
|
||||
{biArray(p.features).map((f, i) => <span key={i} className="feature-tag">{f}</span>)}
|
||||
{biArray(p.features).map((f, i) => (
|
||||
<span key={i} className="feature-tag">
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
<div className="detail-section">
|
||||
<h2 className="detail-section-title">{t('detail.screenshots')}</h2>
|
||||
<ScreenshotCarousel count={3} placeholder={t('detail.screenshotPlaceholder')} />
|
||||
</div>
|
||||
{p.screenshots && p.screenshots.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h2 className="detail-section-title">{t('detail.screenshots')}</h2>
|
||||
<ScreenshotCarousel screenshots={p.screenshots} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Downloads */}
|
||||
{hasDownloads && (
|
||||
<div className="detail-section">
|
||||
<div className="detail-section" id="downloads">
|
||||
<h2 className="detail-section-title">{t('detail.downloads')}</h2>
|
||||
<DownloadTable downloads={p.downloads} />
|
||||
<div className="trust-note">{t('downloads.trustNote')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Roadmap */}
|
||||
{p.roadmap && (
|
||||
<div className="detail-section">
|
||||
@@ -113,10 +154,24 @@ export default function ProjectDetailPage() {
|
||||
<div className="detail-section">
|
||||
<h2 className="detail-section-title">{t('detail.installGuide')}</h2>
|
||||
<div className="install-list">
|
||||
<div className="install-item"><strong>Windows</strong>{t('detail.install.windows')}</div>
|
||||
<div className="install-item"><strong>macOS</strong>{t('detail.install.macos')}</div>
|
||||
<div className="install-item"><strong>Linux</strong>{t('detail.install.linux')}</div>
|
||||
{p.platforms.includes('android') && <div className="install-item"><strong>Android</strong>{t('detail.install.android')}</div>}
|
||||
<div className="install-item">
|
||||
<strong>Windows</strong>
|
||||
{t('detail.install.windows')}
|
||||
</div>
|
||||
<div className="install-item">
|
||||
<strong>macOS</strong>
|
||||
{t('detail.install.macos')}
|
||||
</div>
|
||||
<div className="install-item">
|
||||
<strong>Linux</strong>
|
||||
{t('detail.install.linux')}
|
||||
</div>
|
||||
{p.platforms.includes('android') && (
|
||||
<div className="install-item">
|
||||
<strong>Android</strong>
|
||||
{t('detail.install.android')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,49 +209,19 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-link-grid">
|
||||
<a href={p.repoUrl} target="_blank" className="detail-link-btn"><ExternalLink size={15} />{t('detail.repo')}</a>
|
||||
{p.docsUrl && <a href={p.docsUrl} className="detail-link-btn"><BookOpen size={15} />{t('detail.docs')}</a>}
|
||||
<a href={p.repoUrl} target="_blank" className="detail-link-btn">
|
||||
<ExternalLink size={15} />
|
||||
{t('detail.repo')}
|
||||
</a>
|
||||
{p.docsUrl && (
|
||||
<a href={p.docsUrl} target="_blank" rel="noopener noreferrer" className="detail-link-btn">
|
||||
<BookOpen size={15} />
|
||||
{t('detail.docs')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenshotCarousel({ count, placeholder }: { count: number; placeholder: string }) {
|
||||
const [active, setActive] = useState(0);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollTo = useCallback((i: number) => {
|
||||
setActive(i);
|
||||
scrollRef.current?.children[i]?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => { entries.forEach(e => { if (e.isIntersecting) { const idx = Array.from(el.children).indexOf(e.target); if (idx >= 0) setActive(idx); } }); },
|
||||
{ root: el, threshold: 0.6 }
|
||||
);
|
||||
Array.from(el.children).forEach(c => observer.observe(c));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="screenshot-carousel">
|
||||
<div className="screenshot-scroll" ref={scrollRef}>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} className="screenshot-slide">
|
||||
<div className="screenshot-placeholder">{placeholder} {i + 1}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="screenshot-dots">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<button key={i} className={`screenshot-dot ${i === active ? 'active' : ''}`} onClick={() => scrollTo(i)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,17 +11,24 @@ export default function ProjectsPage() {
|
||||
const catParam = searchParams.get('cat');
|
||||
|
||||
const {
|
||||
search, setSearch,
|
||||
tech, setTech,
|
||||
platform, setPlatform,
|
||||
status, setStatus,
|
||||
sort, setSort,
|
||||
allTech, allPlatforms, allStatuses,
|
||||
search,
|
||||
setSearch,
|
||||
tech,
|
||||
setTech,
|
||||
platform,
|
||||
setPlatform,
|
||||
status,
|
||||
setStatus,
|
||||
sort,
|
||||
setSort,
|
||||
allTech,
|
||||
allPlatforms,
|
||||
allStatuses,
|
||||
filteredProjects,
|
||||
} = useProjectFilters();
|
||||
|
||||
const displayedProjects = catParam
|
||||
? filteredProjects.filter(p => p.type.includes(catParam))
|
||||
? filteredProjects.filter((p) => p.type.includes(catParam))
|
||||
: filteredProjects;
|
||||
|
||||
const techOptions = [
|
||||
@@ -54,7 +61,7 @@ export default function ProjectsPage() {
|
||||
type="text"
|
||||
className="search-input"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('projects.search')}
|
||||
/>
|
||||
|
||||
@@ -90,7 +97,7 @@ export default function ProjectsPage() {
|
||||
{displayedProjects.length === 0 ? (
|
||||
<div className="empty-state">{t('projects.noResults')}</div>
|
||||
) : (
|
||||
displayedProjects.map(p => <ProjectCard key={p.id} project={p} />)
|
||||
displayedProjects.map((p) => <ProjectCard key={p.id} project={p} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user