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:
2026-05-22 13:34:41 +08:00
parent 5e79c96364
commit 6b58b55c32
83 changed files with 5890 additions and 3955 deletions
+98 -73
View File
@@ -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>
);
}