Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
277 changes: 277 additions & 0 deletions docs/components/AnnouncementCards.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/* 首页公告卡片区 —— 全部使用 Rspress 主题变量,浅色/深色自动适配 */

.ann-section {
max-width: 1152px;
margin: 0 auto;
padding: 48px 24px 64px;
}

/* ---------- 头部 ---------- */
.ann-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
}

.ann-eyebrow {
display: block;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
color: var(--rp-c-brand);
margin-bottom: 6px;
}

.ann-title {
margin: 0;
font-size: 28px;
font-weight: 700;
line-height: 1.3;
color: var(--rp-c-text-1);
}

.ann-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: var(--rp-c-text-2);
}

.ann-more {
flex-shrink: 0;
font-size: 14px;
font-weight: 500;
color: var(--rp-c-brand);
text-decoration: none;
padding: 8px 16px;
border: 1px solid var(--rp-c-divider);
border-radius: 999px;
transition: border-color 0.25s ease, background-color 0.25s ease;
}

.ann-more:hover {
border-color: var(--rp-c-brand);
background-color: var(--rp-c-bg-soft);
}

.ann-more-arrow {
display: inline-block;
margin-left: 6px;
transition: transform 0.25s ease;
}

.ann-more:hover .ann-more-arrow {
transform: translateX(3px);
}

.ann-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}

.ann-create {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--rp-c-bg);
background-color: var(--rp-c-brand);
text-decoration: none;
padding: 8px 16px;
border-radius: 999px;
transition: opacity 0.25s ease, transform 0.25s ease;
}

.ann-create:hover {
opacity: 0.88;
transform: translateY(-1px);
}

/* ---------- 卡片网格 ---------- */
.ann-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}

.ann-card {
--ann-lift: 0px;
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
min-height: 148px;
border: 1px solid var(--rp-c-divider);
border-radius: 12px;
background-color: var(--rp-c-bg-soft);
text-decoration: none;
transform: translateY(var(--ann-lift));
transition:
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.3s ease,
box-shadow 0.3s ease;
}

.ann-card:hover {
--ann-lift: -4px;
border-color: var(--rp-c-brand);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}

:root.dark .ann-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}

.ann-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}

.ann-date {
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--rp-c-brand);
}

.ann-badge {
font-size: 12px;
font-weight: 500;
line-height: 1;
padding: 5px 10px;
border-radius: 999px;
white-space: nowrap;
}

.ann-badge-wechat {
color: #07c160;
background-color: rgba(7, 193, 96, 0.1);
}

.ann-badge-gitee {
color: #c71d23;
background-color: rgba(199, 29, 35, 0.08);
}

.ann-badge-other {
color: var(--rp-c-text-2);
background-color: var(--rp-c-bg-mute);
}

.ann-badge-milestone {
color: var(--rp-c-text-2);
background-color: var(--rp-c-bg-mute);
}

/* 无链接的里程碑:静态卡片,不可点击 */
.ann-card-static {
cursor: default;
background-color: var(--rp-c-bg);
}

.ann-card-static:hover {
--ann-lift: 0px;
border-color: var(--rp-c-divider);
box-shadow: none;
}

.ann-card-static:hover .ann-card-title {
color: var(--rp-c-text-1);
}

.ann-card-title {
flex: 1;
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 1.5;
color: var(--rp-c-text-1);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color 0.25s ease;
}

.ann-card:hover .ann-card-title {
color: var(--rp-c-brand);
}

.ann-card-foot {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: var(--rp-c-text-2);
}

.ann-external {
transition: transform 0.25s ease;
}

.ann-card:hover .ann-external {
transform: translate(2px, -2px);
color: var(--rp-c-brand);
}

/* ---------- 滚动进入动画:卡片依次浮入 ---------- */
.ann-card {
opacity: 0;
translate: 0 28px;
}

.ann-revealed .ann-card {
opacity: 1;
translate: 0 0;
transition:
opacity 0.55s cubic-bezier(0.22, 1, 0.36, 1),
translate 0.55s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.3s ease,
box-shadow 0.3s ease;
transition-delay:
calc(var(--ann-i) * 70ms),
calc(var(--ann-i) * 70ms),
0ms,
0ms,
0ms;
}

/* 无障碍:减少动态偏好下直接呈现 */
@media (prefers-reduced-motion: reduce) {
.ann-card {
opacity: 1;
translate: none;
transition: none;
}

.ann-revealed .ann-card {
transition-delay: 0ms;
}
}

/* ---------- 窄屏 ---------- */
@media (max-width: 640px) {
.ann-section {
padding: 32px 20px 48px;
}

.ann-header {
flex-direction: column;
align-items: flex-start;
}

.ann-title {
font-size: 24px;
}

.ann-grid {
grid-template-columns: 1fr;
}
}
121 changes: 121 additions & 0 deletions docs/components/AnnouncementCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react';
import items from './announcements.json';
import './AnnouncementCards.css';

interface Announcement {
title: string;
date: string;
url: string | null;
source: 'wechat' | 'gitee' | 'other' | 'milestone';
}

const SOURCE_LABEL: Record<Announcement['source'], string> = {
wechat: '微信推文',
gitee: 'Gitee',
other: '外部链接',
milestone: '里程碑',
};

/**
* 首页公告卡片区。
* - 数据来自 scripts/gen-announcements.mjs 生成的 announcements.json
* (自动同步 announcement/index.md,无需手工维护)
* - 滚动进入视口时卡片依次浮入(IntersectionObserver + 交错延迟)
* - prefers-reduced-motion 下直接呈现,无动画
*/
export default function AnnouncementCards({ count = 6 }: { count?: number }) {
const sectionRef = useRef<HTMLElement>(null);
const [revealed, setRevealed] = useState(false);

useEffect(() => {
const el = sectionRef.current;
if (!el) return;
// 不支持 IO 的老浏览器直接显示
if (typeof IntersectionObserver === 'undefined') {
setRevealed(true);
return;
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setRevealed(true);
io.disconnect();
}
},
{ threshold: 0.15 }
);
io.observe(el);
return () => io.disconnect();
}, []);

const list = (items as Announcement[]).slice(0, count);

return (
<section
ref={sectionRef}
className={`ann-section${revealed ? ' ann-revealed' : ''}`}
aria-label="社团公告"
>
<div className="ann-header">
<div className="ann-header-text">
<span className="ann-eyebrow">ANNOUNCEMENTS</span>
<h2 className="ann-title">社团公告</h2>
<p className="ann-subtitle">社团动态与活动纪实,点击卡片查看原文</p>
</div>
<div className="ann-actions">
<a className="ann-create" href="/announcement/create">
<span aria-hidden="true">+</span> 创建公告
</a>
<a className="ann-more" href="/announcement/">
查看全部
<span className="ann-more-arrow" aria-hidden="true">→</span>
</a>
</div>
</div>

<div className="ann-grid">
{list.map((item, i) =>
item.url ? (
<a
key={item.url}
className="ann-card"
href={item.url}
target="_blank"
rel="noopener noreferrer"
style={{ ['--ann-i' as string]: i }}
>
<div className="ann-card-top">
{item.date && <span className="ann-date">{item.date}</span>}
<span className={`ann-badge ann-badge-${item.source}`}>
{SOURCE_LABEL[item.source]}
</span>
</div>
<h3 className="ann-card-title">{item.title}</h3>
<div className="ann-card-foot">
<span className="ann-readmore">阅读原文</span>
<span className="ann-external" aria-hidden="true">↗</span>
</div>
</a>
) : (
<div
key={`${item.title}-${i}`}
className="ann-card ann-card-static"
style={{ ['--ann-i' as string]: i }}
>
<div className="ann-card-top">
{item.date && <span className="ann-date">{item.date}</span>}
<span className={`ann-badge ann-badge-${item.source}`}>
{SOURCE_LABEL[item.source]}
</span>
</div>
<h3 className="ann-card-title">{item.title}</h3>
<div className="ann-card-foot">
<span className="ann-readmore">社团里程碑</span>
</div>
</div>
)
)}
</div>
</section>
);
}
Loading
Loading