feat(projects): simplify categories and add cover-ready card layout

This commit is contained in:
zguiyang
2026-03-16 22:30:26 +08:00
parent d873c3b063
commit f76a00c0a6
4 changed files with 148 additions and 188 deletions

View File

@@ -181,6 +181,8 @@ const sharedCases: Project[] = [
'An open AI workspace for builders.', 'An open AI workspace for builders.',
'Used as an R&D track, not the primary professional narrative.', 'Used as an R&D track, not the primary professional narrative.',
], ],
coverImage: '',
coverImageAlt: 'Elynd project cover',
tech: ['TypeScript', 'React', 'AI Workflow', 'Open Source'], tech: ['TypeScript', 'React', 'AI Workflow', 'Open Source'],
link: '#', link: '#',
}, },

View File

@@ -9,82 +9,96 @@ import { defaultLang } from '@/i18n/ui';
const lang = (Astro.currentLocale as Lang) || defaultLang; const lang = (Astro.currentLocale as Lang) || defaultLang;
const isZh = lang === 'zh'; const isZh = lang === 'zh';
const pageProjects = projects[lang]; const pageProjects = projects[lang].map((project) => ({
...project,
const filters = [ category: project.id === 'elynd' ? 'indie' : 'enterprise',
{ key: 'all', label: isZh ? '全部' : 'All' }, }));
{ key: 'product', label: isZh ? '产品系统' : 'Product Systems' }, const enterpriseProjects = pageProjects.filter((project) => project.category === 'enterprise');
{ key: 'client', label: isZh ? '企业项目' : 'Client Systems' }, const indieProjects = pageProjects.filter((project) => project.category === 'indie');
{ key: 'experiment', label: isZh ? '实验项目' : 'Experiments' },
];
--- ---
<Layout title={isZh ? '项目' : 'Projects'}> <Layout title={isZh ? '项目' : 'Projects'}>
<GlassHeader lang={lang} client:load transition:persist="header" /> <GlassHeader lang={lang} client:load transition:persist="header" />
<main class="min-h-screen pt-24 pb-20" data-projects-root> <main class="min-h-screen pt-24 pb-20">
<Container> <Container>
<section class="page-content-main"> <section class="page-content-main">
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl">{isZh ? '工程案例' : 'Engineering Case Studies'}</h1> <h1 class="text-4xl font-bold tracking-tight sm:text-5xl">{isZh ? '项目经历' : 'Project Experience'}</h1>
<p class="mt-4 text-lg leading-relaxed text-muted-foreground"> <p class="mt-4 text-lg leading-relaxed text-muted-foreground">
{isZh {isZh
? '围绕复杂系统建设的真实项目经验,重点展示技术挑战、职责边界和结果。' ? '按两类展示:企业项目与独立开发项目。采用卡片布局,聚焦项目背景、职责和结果。'
: 'Real projects focused on complex system delivery, highlighting technical challenges, responsibilities, and outcomes.'} : 'Organized into two categories: enterprise projects and independent projects, shown in a cover-style card grid.'}
</p> </p>
</section> </section>
<section class="page-content-main mt-8"> <section class="page-content-main mt-8">
<div class="page-surface mb-8 flex flex-wrap gap-2 p-2"> <div class="mb-5 flex items-end justify-between gap-3">
{filters.map((filter, index) => ( <h2 class="text-2xl font-bold tracking-tight">{isZh ? '企业项目' : 'Enterprise Projects'}</h2>
<button <span class="text-sm text-muted-foreground">{enterpriseProjects.length} {isZh ? '个项目' : 'projects'}</span>
type="button" </div>
data-filter={filter.key} <div class="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
aria-pressed={index === 0 ? 'true' : 'false'} {enterpriseProjects.map((project) => (
class={`rounded-md px-4 py-2 text-sm font-semibold ${index === 0 ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'}`} <article class="page-surface overflow-hidden">
> <div class={`bg-gradient-to-br ${project.image.bg} p-5`}>
{filter.label} <p class="text-xs font-semibold uppercase tracking-wider text-foreground/80">{isZh ? '企业项目' : 'Enterprise'}</p>
</button> <h3 class="mt-2 text-lg font-bold">{project.title}</h3>
<p class="mt-2 text-sm text-foreground/80">{project.systemType}</p>
</div>
<div class="p-5">
<p class="text-sm text-muted-foreground">{project.context}</p>
<div class="mt-4 flex flex-wrap gap-2">
{project.tech.slice(0, 5).map((tech) => (
<span class="rounded-md border border-border bg-muted/50 px-2.5 py-1 text-xs font-medium">{tech}</span>
))}
</div>
<p class="mt-4 text-sm font-semibold text-foreground/90">{isZh ? '结果:' : 'Outcome:'} {project.outcomes?.[0] ?? project.impact}</p>
<p class="mt-3 text-xs text-muted-foreground">{isZh ? '受保密协议限制,暂不提供在线预览。' : 'Online preview is not available due to confidentiality restrictions.'}</p>
</div>
</article>
))} ))}
</div> </div>
</section>
<div class="space-y-6"> <section class="page-content-main mt-12">
{pageProjects.map((project) => ( <div class="mb-5 flex items-end justify-between gap-3">
<article data-project-card data-type={project.type} class="page-surface p-6 md:p-8"> <h2 class="text-2xl font-bold tracking-tight">{isZh ? '独立项目' : 'Independent Projects'}</h2>
<div class="grid gap-6 lg:grid-cols-[1.2fr_1fr]"> <span class="text-sm text-muted-foreground">{indieProjects.length} {isZh ? '个项目' : 'projects'}</span>
<div> </div>
<p class="text-xs font-semibold uppercase tracking-wider text-primary">{project.systemType}</p> <div class="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
<h2 class="mt-2 text-2xl font-bold tracking-tight">{project.title}</h2> {indieProjects.map((project) => (
<p class="mt-3 text-muted-foreground">{project.context}</p> <article class="page-surface overflow-hidden">
<div class="relative aspect-[16/9] overflow-hidden border-b border-border/70 bg-muted/30">
<div class="mt-5 flex flex-wrap gap-2"> {project.coverImage ? (
{project.tech.map((tech) => ( <img
<span class="rounded-md border border-border bg-muted/50 px-2.5 py-1 text-xs font-medium">{tech}</span> src={project.coverImage}
))} alt={project.coverImageAlt || project.title}
</div> class="h-full w-full object-cover"
</div> loading="lazy"
decoding="async"
<div class="space-y-4"> />
<div> ) : (
<h3 class="text-sm font-bold uppercase tracking-wider text-foreground/80">{isZh ? '技术挑战' : 'Technical Challenges'}</h3> <div class={`flex h-full w-full flex-col justify-end bg-gradient-to-br ${project.image.bg} p-5`}>
<ul class="mt-2 space-y-1.5 text-sm text-muted-foreground"> <p class="text-xs font-semibold uppercase tracking-wider text-foreground/80">{isZh ? '封面待补充' : 'Cover Coming Soon'}</p>
{project.challenges?.map((item) => <li>• {item}</li>)} <h3 class="mt-2 text-lg font-bold">{project.title}</h3>
</ul> <p class="mt-1 text-sm text-foreground/80">{project.systemType}</p>
</div>
<div>
<h3 class="text-sm font-bold uppercase tracking-wider text-foreground/80">{isZh ? '职责范围' : 'Responsibilities'}</h3>
<ul class="mt-2 space-y-1.5 text-sm text-muted-foreground">
{project.responsibilities?.map((item) => <li>• {item}</li>)}
</ul>
</div>
<div>
<h3 class="text-sm font-bold uppercase tracking-wider text-foreground/80">{isZh ? '结果' : 'Outcomes'}</h3>
<ul class="mt-2 space-y-1.5 text-sm text-foreground/90">
{project.outcomes?.map((item) => <li>• {item}</li>)}
</ul>
</div> </div>
)}
</div>
<div class="p-5">
<p class="text-xs font-semibold uppercase tracking-wider text-primary/80">{isZh ? '独立项目' : 'Independent'}</p>
<h3 class="mt-2 text-lg font-bold">{project.title}</h3>
<p class="text-sm text-muted-foreground">{project.context}</p>
<div class="mt-4 flex flex-wrap gap-2">
{project.tech.slice(0, 5).map((tech) => (
<span class="rounded-md border border-border bg-muted/50 px-2.5 py-1 text-xs font-medium">{tech}</span>
))}
</div> </div>
<p class="mt-4 text-sm font-semibold text-foreground/90">{isZh ? '结果:' : 'Outcome:'} {project.outcomes?.[0] ?? project.impact}</p>
{project.link !== '#' && (
<a href={project.link} target="_blank" rel="noopener noreferrer" class="mt-3 inline-flex text-sm font-semibold text-primary hover:text-primary/80">
{isZh ? '查看项目' : 'Open Project'}
</a>
)}
</div> </div>
</article> </article>
))} ))}
@@ -95,39 +109,3 @@ const filters = [
<Footer lang={lang} client:load /> <Footer lang={lang} client:load />
</Layout> </Layout>
<script>
const root = document.querySelector('[data-projects-root]');
if (!root) {
// no-op
} else {
const buttons = Array.from(root.querySelectorAll('[data-filter]'));
const cards = Array.from(root.querySelectorAll('[data-project-card]'));
const apply = (activeFilter) => {
cards.forEach((card) => {
const cardType = card.getAttribute('data-type');
const visible = activeFilter === 'all' || activeFilter === cardType;
card.classList.toggle('hidden', !visible);
});
buttons.forEach((button) => {
const isActive = button.getAttribute('data-filter') === activeFilter;
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
button.classList.toggle('bg-primary', isActive);
button.classList.toggle('text-primary-foreground', isActive);
button.classList.toggle('text-muted-foreground', !isActive);
button.classList.toggle('hover:bg-muted', !isActive);
button.classList.toggle('hover:text-foreground', !isActive);
});
};
buttons.forEach((button) => {
button.addEventListener('click', () => {
apply(button.getAttribute('data-filter') ?? 'all');
});
});
apply('all');
}
</script>

View File

@@ -9,82 +9,96 @@ import { defaultLang } from '@/i18n/ui';
const lang = (Astro.currentLocale as Lang) || defaultLang; const lang = (Astro.currentLocale as Lang) || defaultLang;
const isZh = lang === 'zh'; const isZh = lang === 'zh';
const pageProjects = projects[lang]; const pageProjects = projects[lang].map((project) => ({
...project,
const filters = [ category: project.id === 'elynd' ? 'indie' : 'enterprise',
{ key: 'all', label: isZh ? '全部' : 'All' }, }));
{ key: 'product', label: isZh ? '产品系统' : 'Product Systems' }, const enterpriseProjects = pageProjects.filter((project) => project.category === 'enterprise');
{ key: 'client', label: isZh ? '企业项目' : 'Client Systems' }, const indieProjects = pageProjects.filter((project) => project.category === 'indie');
{ key: 'experiment', label: isZh ? '实验项目' : 'Experiments' },
];
--- ---
<Layout title={isZh ? '项目' : 'Projects'}> <Layout title={isZh ? '项目' : 'Projects'}>
<GlassHeader lang={lang} client:load transition:persist="header" /> <GlassHeader lang={lang} client:load transition:persist="header" />
<main class="min-h-screen pt-24 pb-20" data-projects-root> <main class="min-h-screen pt-24 pb-20">
<Container> <Container>
<section class="page-content-main"> <section class="page-content-main">
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl">{isZh ? '工程案例' : 'Engineering Case Studies'}</h1> <h1 class="text-4xl font-bold tracking-tight sm:text-5xl">{isZh ? '项目经历' : 'Project Experience'}</h1>
<p class="mt-4 text-lg leading-relaxed text-muted-foreground"> <p class="mt-4 text-lg leading-relaxed text-muted-foreground">
{isZh {isZh
? '围绕复杂系统建设的真实项目经验,重点展示技术挑战、职责边界和结果。' ? '按两类展示:企业项目与独立开发项目。采用卡片布局,聚焦项目背景、职责和结果。'
: 'Real projects focused on complex system delivery, highlighting technical challenges, responsibilities, and outcomes.'} : 'Organized into two categories: enterprise projects and independent projects, shown in a cover-style card grid.'}
</p> </p>
</section> </section>
<section class="page-content-main mt-8"> <section class="page-content-main mt-8">
<div class="page-surface mb-8 flex flex-wrap gap-2 p-2"> <div class="mb-5 flex items-end justify-between gap-3">
{filters.map((filter, index) => ( <h2 class="text-2xl font-bold tracking-tight">{isZh ? '企业项目' : 'Enterprise Projects'}</h2>
<button <span class="text-sm text-muted-foreground">{enterpriseProjects.length} {isZh ? '个项目' : 'projects'}</span>
type="button" </div>
data-filter={filter.key} <div class="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
aria-pressed={index === 0 ? 'true' : 'false'} {enterpriseProjects.map((project) => (
class={`rounded-md px-4 py-2 text-sm font-semibold ${index === 0 ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'}`} <article class="page-surface overflow-hidden">
> <div class={`bg-gradient-to-br ${project.image.bg} p-5`}>
{filter.label} <p class="text-xs font-semibold uppercase tracking-wider text-foreground/80">{isZh ? '企业项目' : 'Enterprise'}</p>
</button> <h3 class="mt-2 text-lg font-bold">{project.title}</h3>
<p class="mt-2 text-sm text-foreground/80">{project.systemType}</p>
</div>
<div class="p-5">
<p class="text-sm text-muted-foreground">{project.context}</p>
<div class="mt-4 flex flex-wrap gap-2">
{project.tech.slice(0, 5).map((tech) => (
<span class="rounded-md border border-border bg-muted/50 px-2.5 py-1 text-xs font-medium">{tech}</span>
))}
</div>
<p class="mt-4 text-sm font-semibold text-foreground/90">{isZh ? '结果:' : 'Outcome:'} {project.outcomes?.[0] ?? project.impact}</p>
<p class="mt-3 text-xs text-muted-foreground">{isZh ? '受保密协议限制,暂不提供在线预览。' : 'Online preview is not available due to confidentiality restrictions.'}</p>
</div>
</article>
))} ))}
</div> </div>
</section>
<div class="space-y-6"> <section class="page-content-main mt-12">
{pageProjects.map((project) => ( <div class="mb-5 flex items-end justify-between gap-3">
<article data-project-card data-type={project.type} class="page-surface p-6 md:p-8"> <h2 class="text-2xl font-bold tracking-tight">{isZh ? '独立项目' : 'Independent Projects'}</h2>
<div class="grid gap-6 lg:grid-cols-[1.2fr_1fr]"> <span class="text-sm text-muted-foreground">{indieProjects.length} {isZh ? '个项目' : 'projects'}</span>
<div> </div>
<p class="text-xs font-semibold uppercase tracking-wider text-primary">{project.systemType}</p> <div class="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
<h2 class="mt-2 text-2xl font-bold tracking-tight">{project.title}</h2> {indieProjects.map((project) => (
<p class="mt-3 text-muted-foreground">{project.context}</p> <article class="page-surface overflow-hidden">
<div class="relative aspect-[16/9] overflow-hidden border-b border-border/70 bg-muted/30">
<div class="mt-5 flex flex-wrap gap-2"> {project.coverImage ? (
{project.tech.map((tech) => ( <img
<span class="rounded-md border border-border bg-muted/50 px-2.5 py-1 text-xs font-medium">{tech}</span> src={project.coverImage}
))} alt={project.coverImageAlt || project.title}
</div> class="h-full w-full object-cover"
</div> loading="lazy"
decoding="async"
<div class="space-y-4"> />
<div> ) : (
<h3 class="text-sm font-bold uppercase tracking-wider text-foreground/80">{isZh ? '技术挑战' : 'Technical Challenges'}</h3> <div class={`flex h-full w-full flex-col justify-end bg-gradient-to-br ${project.image.bg} p-5`}>
<ul class="mt-2 space-y-1.5 text-sm text-muted-foreground"> <p class="text-xs font-semibold uppercase tracking-wider text-foreground/80">{isZh ? '封面待补充' : 'Cover Coming Soon'}</p>
{project.challenges?.map((item) => <li>• {item}</li>)} <h3 class="mt-2 text-lg font-bold">{project.title}</h3>
</ul> <p class="mt-1 text-sm text-foreground/80">{project.systemType}</p>
</div>
<div>
<h3 class="text-sm font-bold uppercase tracking-wider text-foreground/80">{isZh ? '职责范围' : 'Responsibilities'}</h3>
<ul class="mt-2 space-y-1.5 text-sm text-muted-foreground">
{project.responsibilities?.map((item) => <li>• {item}</li>)}
</ul>
</div>
<div>
<h3 class="text-sm font-bold uppercase tracking-wider text-foreground/80">{isZh ? '结果' : 'Outcomes'}</h3>
<ul class="mt-2 space-y-1.5 text-sm text-foreground/90">
{project.outcomes?.map((item) => <li>• {item}</li>)}
</ul>
</div> </div>
)}
</div>
<div class="p-5">
<p class="text-xs font-semibold uppercase tracking-wider text-primary/80">{isZh ? '独立项目' : 'Independent'}</p>
<h3 class="mt-2 text-lg font-bold">{project.title}</h3>
<p class="text-sm text-muted-foreground">{project.context}</p>
<div class="mt-4 flex flex-wrap gap-2">
{project.tech.slice(0, 5).map((tech) => (
<span class="rounded-md border border-border bg-muted/50 px-2.5 py-1 text-xs font-medium">{tech}</span>
))}
</div> </div>
<p class="mt-4 text-sm font-semibold text-foreground/90">{isZh ? '结果:' : 'Outcome:'} {project.outcomes?.[0] ?? project.impact}</p>
{project.link !== '#' && (
<a href={project.link} target="_blank" rel="noopener noreferrer" class="mt-3 inline-flex text-sm font-semibold text-primary hover:text-primary/80">
{isZh ? '查看项目' : 'Open Project'}
</a>
)}
</div> </div>
</article> </article>
))} ))}
@@ -95,39 +109,3 @@ const filters = [
<Footer lang={lang} client:load /> <Footer lang={lang} client:load />
</Layout> </Layout>
<script>
const root = document.querySelector('[data-projects-root]');
if (!root) {
// no-op
} else {
const buttons = Array.from(root.querySelectorAll('[data-filter]'));
const cards = Array.from(root.querySelectorAll('[data-project-card]'));
const apply = (activeFilter) => {
cards.forEach((card) => {
const cardType = card.getAttribute('data-type');
const visible = activeFilter === 'all' || activeFilter === cardType;
card.classList.toggle('hidden', !visible);
});
buttons.forEach((button) => {
const isActive = button.getAttribute('data-filter') === activeFilter;
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
button.classList.toggle('bg-primary', isActive);
button.classList.toggle('text-primary-foreground', isActive);
button.classList.toggle('text-muted-foreground', !isActive);
button.classList.toggle('hover:bg-muted', !isActive);
button.classList.toggle('hover:text-foreground', !isActive);
});
};
buttons.forEach((button) => {
button.addEventListener('click', () => {
apply(button.getAttribute('data-filter') ?? 'all');
});
});
apply('all');
}
</script>

View File

@@ -61,6 +61,8 @@ export interface Project {
tech: string[]; tech: string[];
link: string; link: string;
featured?: boolean; featured?: boolean;
coverImage?: string;
coverImageAlt?: string;
links?: { links?: {
github?: string; github?: string;
demo?: string; demo?: string;