Skip to content
Merged
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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ npm run dev
- Astro Content Collections
- Markdown 内容
- 原生 CSS
- 原生 JavaScript 主题切换与移动导航
- 原生 JavaScript 主题切换、移动导航、轮播分页和阅读位置恢复

页面层不依赖 React、用户系统或客户端状态框架。

```text
src/
├── components/ # 页头、页脚、页面导语和视觉组件
├── components/ # 站点、通用界面和内容交互组件
├── content/
│ ├── docs/ # 系列文档与章节
│ ├── interviews/ # 访谈正文与元数据
Expand All @@ -50,7 +50,7 @@ src/
│ ├── news/ # 动态目录与文章详情路由
│ ├── about.astro
│ └── index.astro
└── styles/global.css # 完整视觉系统和响应式规则
└── styles/ # 模块化视觉系统与集中响应式规则
```

## 内容约定
Expand All @@ -59,6 +59,7 @@ src/
- 访谈位于 `src/content/interviews/`,单篇 Markdown 对应一个详情页。
- 三类内容统一公开日期和阅读时间,并通过 `tone: light | mid | dark` 配置单篇视觉色调;文档、访谈和动态均可将任意多篇内容设置为 `featured: true`,集合页会将它们组成精选展板,未指定时自动突出一篇内容。
- 精选展板是否自动翻页及翻页间隔统一在 `src/data/site.ts` 的 `featuredCarouselConfig` 中按栏目配置。
- 三个集合页共享阅读返回状态:从列表进入详情后,返回按钮会恢复原分页和滚动位置。
- 访谈使用 `affiliation` 记录系所;设置 `anonymous: true` 时隐藏姓名,未提供系所则显示“系所信息保密”。
- 内容字段由 `src/content.config.ts` 校验。
- 默认语言为简体中文;当前不配置多语言路由。
Expand Down
24 changes: 23 additions & 1 deletion docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ GeoGenesis 的几何站点标记。它没有 Props,颜色继承父元素,可
- `overlay?: boolean`:让页头覆盖在首页等深色 Hero 上。
- 导航数据来自 `src/data/site.ts`,不要在组件内重复维护。
- 主题选择保存到 `localStorage`。
- 窄屏导航使用全屏抽屉;打开时锁定页面滚动并通过 `inert` 隔离背景内容,点击链接、遮罩或按下 Escape 均会关闭。
- 菜单按钮同步维护 `aria-expanded`,抽屉开关不依赖前端框架。

### `SiteFooter.astro`

Expand Down Expand Up @@ -44,6 +46,15 @@ GeoGenesis 的几何站点标记。它没有 Props,颜色继承父元素,可
- `primaryDatetime?: Date`:存在时将日期渲染为语义化 `<time>`。
- `className?: string`:附加样式类。

### `ArticleBackLink.astro`

文档、访谈和动态阅读页共用的返回按钮。默认优先使用浏览器历史返回;当集合页记录了返回状态时,则恢复原栏目 URL、页内分页和滚动位置。

- `href: string`:没有可恢复状态时的栏目入口。
- `label: string`:无障碍名称与悬停提示。
- `returnStateKey?: string`:对应栏目返回状态的 `sessionStorage` 键;统一使用 `collectionReturnStateKey()` 生成。
- 仅拦截无修饰键的主按钮点击,不影响新标签页等浏览器原生操作。

### `PageIntro.astro`

集合页顶部的栏目名称、主标题、导语和中性站点标识。通常由 `CollectionLayout` 调用,不在页面中重复组合。
Expand Down Expand Up @@ -73,13 +84,22 @@ GeoGenesis 的几何站点标记。它没有 Props,颜色继承父元素,可

### `CollectionIndex.astro`

文档、访谈、动态三个集合索引页共用的页面骨架。它组合 `CollectionLayout`、`FeaturedContentCarousel`、`SectionHeading` 与 `PaginatedContentList`,三个索引页只负责查询和规范化数据后传入。
文档、访谈、动态三个集合索引页共用的页面骨架。它组合 `CollectionLayout`、`FeaturedContentCarousel`、`SectionHeading`、`PaginatedContentList` 与 `CollectionReturnState`,三个索引页只负责查询和规范化数据后传入。

- `section: 'docs' | 'interviews' | 'news'`:栏目标识,用于读取文案配置、轮播配置和分页数据分片。
- `featured: ContentListItem[]`:精选内容。
- `listing: ContentListItem[]`:完整列表数据。
- 各栏目文案(标题、导语、标识、列表标题等)集中维护在 `src/data/sections.ts` 的 `collectionSections`,轮播自动翻页配置仍在 `src/data/site.ts` 的 `featuredCarouselConfig`。

### `CollectionReturnState.astro`

三个集合页共用的阅读返回状态控制器,由 `CollectionIndex` 自动挂载,不应在具体页面中重复实现。

- `section: 'docs' | 'interviews' | 'news'`:用于生成栏目路径与隔离的状态键。
- 进入同栏目详情页前记录当前 URL、滚动位置和分页页码。
- 阅读页通过 `ArticleBackLink` 发起恢复;返回集合页后先恢复分页,再恢复滚动位置,并清除一次性状态。
- 状态解析包含容错,异常或跨站 URL 不会覆盖正常链接行为。

### `ContentVisual.astro`

按 `ContentListItem` 的 `visual` 字段选择并渲染对应的视觉组件(`DocumentArt`、`InterviewArt` 或 `NewsArt`),让 `FeaturedContentCarousel` 与 `PaginatedContentList` 不必各自维护类型映射。
Expand All @@ -98,6 +118,7 @@ GeoGenesis 的几何站点标记。它没有 Props,颜色继承父元素,可
- `label?: string`:轮播区域的无障碍名称。
- 自动翻页由 `src/data/site.ts` 的 `featuredCarouselConfig` 分栏目配置,不在界面中展示额外的播放控制;鼠标悬停、键盘焦点进入或页面进入后台时会暂时停止计时。
- 首尾使用无障碍隐藏的克隆展板衔接,点击同一方向的翻页按钮时始终保持一致的运动方向。
- 触屏设备支持横向滑动翻页;窄屏控制按钮固定在卡片内部并跟随明暗主题。
- 系统开启“减少动态效果”时不启用自动翻页,并将滑动过渡降至最低。

### `FeaturedContentCard.astro`
Expand Down Expand Up @@ -132,6 +153,7 @@ GeoGenesis 的几何站点标记。它没有 Props,颜色继承父元素,可
- `className?: string`:附加到实际列表容器的样式类。
- 分页大小统一由 `src/utils/contentListing.ts` 的 `LIST_PAGE_SIZE` 控制。
- 总页数超过九页时,索引只保留首尾页和当前页附近的分页点,其余范围使用省略标识隐藏。
- 当前页同步到 `data-current-page`;切换完成后触发 `content-page-change`,供返回状态控制器恢复分页和滚动位置。

### `NewsArt.astro`

Expand Down
4 changes: 4 additions & 0 deletions docs/layouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
- `visual` 插槽:`NewsArt`、`InterviewArt` 或真实图片。
- `aside` 插槽:访谈页面的受访者简介;动态页面不使用。
- 默认插槽:Markdown 正文。
- 提供 `meta` 插槽时,布局会在日期左侧自动加入对应栏目的 `ArticleBackLink`;从集合页进入时可恢复原分页和滚动位置。

## `DocumentReaderLayout.astro`

Expand All @@ -50,6 +51,9 @@
- `next?`:下一章节入口。
- `visual` 插槽:可选的系列视觉。
- 默认插槽:Markdown 正文。
- 桌面端保留左侧章节导航与右侧页内目录;手机端将两者合并为同一行的互斥下拉面板,并支持点击外部或按 Escape 关闭。
- 日期左侧的 `ArticleBackLink` 返回文档库。切换章节或页内锚点不会覆盖集合页状态,返回时仍可恢复进入阅读页前的位置。
- 手机端侧栏由断点样式直接隐藏,避免页面加载或章节切换时短暂显示桌面导航。

## 使用边界

Expand Down
12 changes: 12 additions & 0 deletions docs/visual-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,23 @@ GeoGenesis 使用克制的地球科学编辑风格:大面积采用石灰岩暖

首页地球图片保留低饱和自然色;页脚、深色卡片和关于页 CTA 使用接近黑色的深岩绿。长文阅读区始终保持中性背景,避免颜色干扰阅读。

## 响应式策略

桌面布局是视觉基准,响应式调整集中在 `responsive/1000.css`、`responsive/760.css` 与 `responsive/430.css`,避免在基础模块中散布媒体查询。

- `1000px` 以下收紧页面边距和复杂网格,为平板及小型笔记本保留桌面信息层级。
- `760px` 以下启用手机导航、文档下拉导航、纵向页脚和紧凑内容卡片;精选轮播按钮移入卡片并保持至少 44px 触控区域。
- `430px` 以下进一步压缩 Hero、内容视觉和卡片高度;首页数字统计直接切换为单列三行,不经过不稳定的两列状态。
- 手机端降低抽象视觉与真实图片所占高度,提高标题、摘要和元信息的首屏密度;日期与阅读时间保持分居两侧。
- 长文表格允许横向滚动,正文宽度和固定控件同时考虑安全区与窄屏边距。
- 触屏交互不依赖 Hover;按钮颜色、背景和透明度均使用语义令牌,保证明暗主题一致。

## 交互

- 当前导航使用主题色文字,不添加圆点或粗下划线。
- 主要按钮使用深海岩绿,悬停时仅轻微改变明度。
- 正文链接、键盘焦点和阅读进度使用主题色。
- 明暗切换使用无边框单图标按钮,只通过轻微底色和图标淡入淡出反馈状态。
- 集合页进入阅读页时保存栏目 URL、分页与滚动位置;阅读页返回按钮恢复该上下文,章节切换和页内锚点不改变返回目标。
- 中文长篇正文以 18px 为桌面阅读基准,卡片摘要保持在 15–16px;装饰坐标等极小信息不低于 11px。
- 动效保持轻微,并遵循 `prefers-reduced-motion`。
2 changes: 2 additions & 0 deletions src/components/content/CollectionIndex.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
import CollectionLayout from '../../layouts/CollectionLayout.astro';
import CollectionReturnState from './CollectionReturnState.astro';
import FeaturedContentCarousel from './FeaturedContentCarousel.astro';
import PaginatedContentList from './PaginatedContentList.astro';
import SectionHeading from '../ui/SectionHeading.astro';
Expand Down Expand Up @@ -42,4 +43,5 @@ const carousel = featuredCarouselConfig[section];

<PaginatedContentList items={listing} section={section} className={config.listClass} />
</section>
<CollectionReturnState section={section} />
</CollectionLayout>
121 changes: 121 additions & 0 deletions src/components/content/CollectionReturnState.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
---
import { collectionReturnStateKey, type ListingSection } from '../../utils/contentListing';
import { sitePath } from '../../utils/paths';

interface Props {
section: ListingSection;
}

const { section } = Astro.props;
const stateKey = collectionReturnStateKey(section);
const collectionPath = sitePath(`/${section}/`);
---

<span
data-collection-return-state
data-state-key={stateKey}
data-collection-path={collectionPath}
hidden
></span>

<script>
interface ReturnState {
url?: string;
scrollY?: number;
page?: number;
}

const parseReturnState = (value: string | null): ReturnState | undefined => {
if (!value) return undefined;
try {
return JSON.parse(value) as ReturnState;
} catch {
return undefined;
}
};

const normalizePath = (pathname: string) => pathname.replace(/\/+$/, '') || '/';

document.querySelectorAll<HTMLElement>('[data-collection-return-state]').forEach((container) => {
const stateKey = container.dataset.stateKey;
const collectionPath = normalizePath(container.dataset.collectionPath ?? '/');
if (!stateKey) return;

const restoreKey = `${stateKey}:restore`;
const restoreRequested = sessionStorage.getItem(restoreKey) === 'true';

if (restoreRequested) {
const state = parseReturnState(sessionStorage.getItem(stateKey));
const targetPage = Math.max(1, Number(state?.page ?? 1));
const targetScroll = Math.max(0, Number(state?.scrollY ?? 0));
const pagination = document.querySelector<HTMLElement>('[data-content-pagination]');
let restored = false;

const finishRestore = () => {
if (restored) return;
restored = true;
requestAnimationFrame(() => requestAnimationFrame(() => {
window.scrollTo({ top: targetScroll, behavior: 'auto' });
sessionStorage.removeItem(restoreKey);
sessionStorage.removeItem(stateKey);
}));
};

if (targetPage > 1 && pagination) {
pagination.addEventListener('content-page-change', (event) => {
const page = (event as CustomEvent<{ page?: number }>).detail?.page;
if (page === targetPage) finishRestore();
});

let attempts = 0;
const selectPage = () => {
const pageButton = pagination.querySelector<HTMLButtonElement>(`[data-page="${targetPage}"]`);
if (pageButton) {
pageButton.click();
window.setTimeout(finishRestore, 2500);
} else if (attempts < 20) {
attempts += 1;
window.setTimeout(selectPage, 50);
} else {
finishRestore();
}
};
selectPage();
} else {
finishRestore();
}
} else {
sessionStorage.removeItem(stateKey);
}

document.addEventListener('click', (event) => {
if (
event.defaultPrevented
|| event.button !== 0
|| event.metaKey
|| event.ctrlKey
|| event.shiftKey
|| event.altKey
|| !(event.target instanceof Element)
) return;

const link = event.target.closest<HTMLAnchorElement>('a[href]');
if (!link) return;

const target = new URL(link.href);
const targetPath = normalizePath(target.pathname);
if (
target.origin === window.location.origin
&& targetPath !== collectionPath
&& targetPath.startsWith(`${collectionPath}/`)
) {
const pagination = document.querySelector<HTMLElement>('[data-content-pagination]');
sessionStorage.setItem(stateKey, JSON.stringify({
url: window.location.href,
scrollY: window.scrollY,
page: Number(pagination?.dataset.currentPage ?? '1'),
}));
}
});
});
</script>
48 changes: 45 additions & 3 deletions src/components/content/FeaturedContentCarousel.astro
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@ const panels = hasMultiple
<>
<button class="featured-carousel-arrow is-previous" type="button" aria-label="上一篇精选" data-carousel-previous>
<svg viewBox="0 0 32 32" aria-hidden="true">
<path d="M19.5 6.5 10 16l9.5 9.5M10.5 16H25" />
<path d="M20.5 6.5 11 16l9.5 9.5" />
</svg>
</button>
<button class="featured-carousel-arrow is-next" type="button" aria-label="下一篇精选" data-carousel-next>
<svg viewBox="0 0 32 32" aria-hidden="true">
<path d="m12.5 6.5 9.5 9.5-9.5 9.5M21.5 16H7" />
<path d="m11.5 6.5 9.5 9.5-9.5 9.5" />
</svg>
</button>
<div class="featured-carousel-footer">
Expand All @@ -99,14 +99,15 @@ const panels = hasMultiple

<script>
document.querySelectorAll<HTMLElement>('[data-featured-carousel]').forEach((carousel) => {
const viewport = carousel.querySelector<HTMLElement>('.featured-carousel-viewport');
const track = carousel.querySelector<HTMLElement>('[data-carousel-track]');
const panels = [...carousel.querySelectorAll<HTMLElement>('[data-carousel-panel]')];
const slides = [...carousel.querySelectorAll<HTMLElement>('[data-carousel-original]')];
const dots = [...carousel.querySelectorAll<HTMLButtonElement>('[data-carousel-dot]')];
const previous = carousel.querySelector<HTMLButtonElement>('[data-carousel-previous]');
const next = carousel.querySelector<HTMLButtonElement>('[data-carousel-next]');
const status = carousel.querySelector<HTMLElement>('[data-carousel-status]');
if (!track || slides.length < 2 || !previous || !next || !status) return;
if (!viewport || !track || slides.length < 2 || !previous || !next || !status) return;

const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const autoplayConfigured = carousel.dataset.autoplay === 'true';
Expand Down Expand Up @@ -181,6 +182,47 @@ const panels = hasMultiple
next.addEventListener('click', () => move(1));
dots.forEach((dot, index) => dot.addEventListener('click', () => goTo(index)));

let touchStart: { id: number; x: number; y: number } | undefined;
let suppressClick = false;

viewport.addEventListener('pointerdown', (event) => {
if (event.pointerType !== 'touch') return;
touchStart = { id: event.pointerId, x: event.clientX, y: event.clientY };
pausedByInteraction = true;
stopTimer();
});

viewport.addEventListener('pointerup', (event) => {
if (!touchStart || event.pointerId !== touchStart.id) return;
const deltaX = event.clientX - touchStart.x;
const deltaY = event.clientY - touchStart.y;
touchStart = undefined;
pausedByInteraction = false;

if (Math.abs(deltaX) >= 50 && Math.abs(deltaX) > Math.abs(deltaY) * 1.25) {
suppressClick = true;
move(deltaX < 0 ? 1 : -1);
window.setTimeout(() => {
suppressClick = false;
}, 0);
} else {
schedule();
}
});

viewport.addEventListener('pointercancel', () => {
touchStart = undefined;
pausedByInteraction = false;
schedule();
});

viewport.addEventListener('click', (event) => {
if (suppressClick) {
event.preventDefault();
event.stopPropagation();
}
}, { capture: true });

carousel.addEventListener('mouseenter', () => {
pausedByInteraction = true;
stopTimer();
Expand Down
6 changes: 6 additions & 0 deletions src/components/content/PaginatedContentList.astro
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ const totalPages = Math.ceil(items.length / LIST_PAGE_SIZE);
};

const renderIndex = () => {
pagination.dataset.currentPage = String(currentPage);
previous.disabled = loading || currentPage === 1;
next.disabled = loading || currentPage === totalPages;
dots.replaceChildren();
Expand All @@ -187,6 +188,7 @@ const totalPages = Math.ceil(items.length / LIST_PAGE_SIZE);
const dot = document.createElement('button');
dot.type = 'button';
dot.className = 'content-page-dot';
dot.dataset.page = String(page);
dot.setAttribute('aria-label', `第 ${page} 页`);
dot.title = `第 ${page} 页`;
if (page === currentPage) {
Expand Down Expand Up @@ -226,7 +228,11 @@ const totalPages = Math.ceil(items.length / LIST_PAGE_SIZE);
}

currentPage = page;
pagination.dataset.currentPage = String(currentPage);
status.textContent = `第 ${page} 页,共 ${totalPages} 页`;
pagination.dispatchEvent(new CustomEvent('content-page-change', {
detail: { page: currentPage },
}));
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
list.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
} catch {
Expand Down
Loading
Loading