资源展示页组件开发
概述
本文记录将资源展示页(原 Teek 自带的 imgCard yaml 列表)重构为自定义 MRDSGrid 组件的完整过程。除了组件本身,还涉及数据流插件(自动从 .md frontmatter 提取)、permalink 路由在生产环境下的 404 修复(404.html trick + Cloudflare Pages Functions)、全站右键屏蔽等周边工作。
组件需求
旧展示页用 Teek 自带 imgCard 容器 + 200+ 行 yaml 列表,有几个明显痛点:
- 资源多了 yaml 维护成本高(加一篇要改两处:.md + 展示页 yaml)
- 没有搜索 / 分类筛选 / 分页
- 数据耦合在展示页 yaml(单一改动源失败)
新组件需求:
- 客户端搜索(标题 / 简介 / 作者 / 标签)
- 分类筛选(按顶层目录自动扩展)
- 分页
- URL hash 同步(
#q=红石&cat=红石&page=2) - 16:9 封面,自适应卡片高度
- 暗色模式辉光(深底色下传统阴影看不见)
- 首屏 HTML 完整(SSG 友好,不依赖 client fetch)
- 数据从 .md frontmatter 自动来(单一数据源)
组件实现
创建文件 docs/.vitepress/theme/components/MRDSGrid.vue(~13.5KB):
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { data as mrdsData } from 'virtual:mrds-data'
export interface Resource {
id: string
title: string
link: string
cover: string
description?: string
author?: { name: string; avatar?: string; link?: string; subtitle?: string }
category?: string
tags?: string[]
date?: string
}
const props = withDefaults(
defineProps<{
data?: Resource[]
perPage?: number
}>(),
{
perPage: 12,
data: () => mrdsData as unknown as Resource[],
},
)
const query = ref('')
const currentCategory = ref<string | null>(null)
const currentPage = ref(1)
const categories = computed(() => {
const set = new Set<string>()
for (const r of props.data) if (r.category) set.add(r.category)
return Array.from(set)
})
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
return props.data.filter((r) => {
if (currentCategory.value && r.category !== currentCategory.value) return false
if (!q) return true
const haystack = [r.title, r.description ?? '', r.author?.name ?? '', ...(r.tags ?? [])]
.join(' ').toLowerCase()
return haystack.includes(q)
})
})
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / props.perPage)))
const visibleIds = computed(() => {
const start = (currentPage.value - 1) * props.perPage
const end = start + props.perPage
const ids = new Set<string>()
for (let i = start; i < end && i < filtered.value.length; i++) {
ids.add(filtered.value[i].id)
}
return ids
})
function isVisible(item: Resource): boolean {
return visibleIds.value.has(item.id)
}
function shouldEagerLoad(item: Resource): boolean {
if (currentPage.value !== 1) return false
const idx = filtered.value.findIndex((r) => r.id === item.id)
return idx >= 0 && idx < 4
}
function goToPage(p: number) {
if (p < 1 || p > totalPages.value || p === currentPage.value) return
currentPage.value = p
if (typeof window !== 'undefined') {
document.querySelector('.mrds-toolbar')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
function setCategory(cat: string | null) {
currentCategory.value = cat
currentPage.value = 1
}
function clearAll() {
query.value = ''
currentCategory.value = null
currentPage.value = 1
}
// URL hash 同步
function readHash() {
if (typeof window === 'undefined') return
const h = window.location.hash.replace(/^#/, '')
if (!h) return
const params = new URLSearchParams(h)
const q = params.get('q')
const cat = params.get('cat')
const p = Number(params.get('page') ?? '1')
if (q) query.value = q
if (cat) currentCategory.value = cat
if (!Number.isNaN(p) && p >= 1) currentPage.value = p
}
function writeHash() {
if (typeof window === 'undefined') return
const params = new URLSearchParams()
if (query.value) params.set('q', query.value)
if (currentCategory.value) params.set('cat', currentCategory.value)
if (currentPage.value !== 1) params.set('page', String(currentPage.value))
const s = params.toString()
const url = s ? `#${s}` : window.location.pathname + window.location.search
window.history.replaceState(null, '', url)
}
onMounted(() => { readHash() })
watch([query, currentCategory, currentPage], () => { writeHash() })
</script>
<template>
<div class="mrds-grid">
<!-- 顶部工具栏:搜索 + 分类筛选 -->
<div class="mrds-toolbar">
<div class="mrds-search">
<svg class="mrds-search-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
<input v-model="query" type="search" placeholder="搜索资源..." class="mrds-search-input" />
<button v-if="query" class="mrds-search-clear" @click="query = ''" aria-label="清空搜索">×</button>
</div>
<div v-if="categories.length > 0" class="mrds-categories">
<button class="mrds-cat" :class="{ active: currentCategory === null }" @click="setCategory(null)">全部</button>
<button v-for="cat in categories" :key="cat" class="mrds-cat" :class="{ active: currentCategory === cat }" @click="setCategory(cat)">{{ cat }}</button>
</div>
</div>
<div class="mrds-meta">
<span v-if="filtered.length !== data.length">
找到 <strong>{{ filtered.length }}</strong> / {{ data.length }} 个资源
<button v-if="query || currentCategory" class="mrds-clear-all" @click="clearAll">清除筛选</button>
</span>
<span v-else>共 {{ data.length }} 个资源</span>
</div>
<!-- 卡片网格 -->
<div class="mrds-items">
<a v-for="item in data" :key="item.id" :href="item.link" class="mrds-card" :class="{ 'mrds-card-hidden': !isVisible(item) }" :data-category="item.category ?? ''">
<div class="mrds-card-cover">
<img :src="item.cover" :alt="item.title" :loading="shouldEagerLoad(item) ? 'eager' : 'lazy'" :fetchpriority="shouldEagerLoad(item) ? 'high' : 'auto'" decoding="async" width="800" height="450" />
<span v-if="item.category" class="mrds-card-badge">{{ item.category }}</span>
</div>
<div class="mrds-card-body">
<h3 class="mrds-card-title">{{ item.title }}</h3>
<p v-if="item.description" class="mrds-card-desc">{{ item.description }}</p>
<div v-if="item.author" class="mrds-card-author">
<img v-if="item.author.avatar" :src="item.author.avatar" :alt="item.author.name" class="mrds-avatar" loading="lazy" width="24" height="24" />
<span class="mrds-author-name">
{{ item.author.name }}
<span v-if="item.author.subtitle" class="mrds-author-subtitle">{{ item.author.subtitle }}</span>
</span>
<span v-if="item.date" class="mrds-date">{{ item.date }}</span>
</div>
</div>
</a>
</div>
<div v-if="filtered.length === 0" class="mrds-empty">
<p>没有找到匹配的资源</p>
<button class="mrds-clear-all" @click="clearAll">清除筛选</button>
</div>
<nav v-if="totalPages > 1" class="mrds-pagination" aria-label="分页">
<button class="mrds-page-btn" :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">← 上一页</button>
<button v-for="p in totalPages" :key="p" class="mrds-page-num" :class="{ active: currentPage === p }" @click="goToPage(p)">{{ p }}</button>
<button class="mrds-page-btn" :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">下一页 →</button>
</nav>
</div>
</template>
<style scoped lang="scss">
/* 16:9 封面 + 暗色模式辉光 + 搜索/分类/分页样式 */
.mrds-grid { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
.mrds-card {
display: flex; flex-direction: column;
border-radius: 12px; overflow: hidden;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
text-decoration: none; color: inherit;
transition: transform 0.25s, box-shadow 0.25s, border-color 0.25s;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
border-color: var(--vp-c-brand-1);
}
&.mrds-card-hidden { display: none; }
}
.mrds-card-cover {
position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden;
background: var(--vp-c-gutter);
img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s; }
}
.mrds-card:hover .mrds-card-cover img { transform: scale(1.05); }
.mrds-card-badge {
position: absolute; top: 10px; left: 10px;
padding: 3px 10px;
background: rgba(0, 0, 0, 0.65); color: #fff;
font-size: 11px; border-radius: 999px;
backdrop-filter: blur(8px);
}
/* 暗色模式:hover 抬升 + 辉光(深底色下传统阴影看不到,用品牌色发光更明显) */
.dark .mrds-card {
&:hover {
transform: translateY(-4px);
box-shadow:
0 0 0 1px var(--vp-c-brand-1),
0 0 24px rgba(64, 158, 255, 0.45),
0 0 48px rgba(64, 158, 255, 0.25);
border-color: var(--vp-c-brand-1);
}
}
.mrds-card-body { flex: 1; display: flex; flex-direction: column; padding: 14px 16px 16px; }
.mrds-card-title {
font-size: 15px; font-weight: 600; line-height: 1.4;
color: var(--vp-c-text-1); margin: 0 0 8px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.mrds-card-desc {
flex: 1; font-size: 13px; line-height: 1.55;
color: var(--vp-c-text-2); margin: 0 0 12px;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden;
}
.mrds-author-subtitle {
margin-left: 4px;
font-weight: 400; font-size: 11px;
color: var(--vp-c-text-3); font-style: italic;
}
/* 工具栏 / 分类 / 分页样式略,详见 MRDSGrid.vue 源文件 */
</style>上面代码省略了部分工具栏和分页的样式细节,完整代码见
MRDSGrid.vue源文件。
组件注册
在 docs/.vitepress/theme/index.ts 中注册组件:
import MRDSGrid from "./components/MRDSGrid.vue";
export default {
enhanceApp({ app }: { app: any }) {
app.component('MRDSGrid', MRDSGrid);
},
}页面使用
docs/@pages/资源展示.md 从 200+ 行 yaml 列表改成一行组件调用:
---
title: 资源展示
permalink: /mrds
layout: page
article: false
sidebar: false
---
<div class="page-header">
<h1 class="gradient-title">Mcoo 墨客小筑 资源下载</h1>
<p class="subtitle">点击卡片查看资源详情,支持搜索和分类筛选</p>
</div>
<MRDSGrid />数据流插件(mrds-data-loader.ts)
为了让组件不依赖 props 就能拿到数据,写了一个 Vite 插件,build time 扫所有 .md 自动生成数据:
// docs/.vitepress/theme/plugins/mrds-data-loader.ts(节选)
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { join, relative, sep, resolve } from 'node:path'
import type { Plugin } from 'vite'
const RESOURCE_DIR = '004.Mcoo Resource Download Site'
const DATA_FILE_ABS = resolve(__dirname, '../data/mrds-data.js')
const VIRTUAL_ID = 'virtual:mrds-data'
const RESOLVED_VIRTUAL_ID = '\0' + VIRTUAL_ID
// 极简 YAML 解析(不装 gray-matter,自己写够用)
function parseFrontmatter(yaml: string): Record<string, YamlValue> { /* ... */ }
// 关键自动补全
function extractCategoryFromPath(filePath: string): string {
// "001.红石/xxx.md" → "红石"
const parts = filePath.split(sep)
const dir = parts[parts.length - 2]
return dir.replace(/^\d+\.?\s*/, '').trim()
}
function extractCoverFromContent(content: string): string {
// 从正文抓第一张图 URL
const m = content.match(/https?:\/\/[^\s'"<>)]+\.(?:png|jpg|jpeg|webp|gif)/i)
return m?.[0] ?? ''
}
function extractAuthorCardInfo(content: string) {
// 从 <AuthorCard avatar="..." name="..." link="..." subtitle="..."> 读
const m = content.match(/<AuthorCard\b[^>]*\/?\s*>/s)
/* ... */
}
function buildResource(filePath: string, docsRoot: string): Resource {
const raw = readFileSync(filePath, 'utf-8')
const { yaml, content } = splitFrontmatter(raw)
const fm = parseFrontmatter(yaml)
const fromCard = extractAuthorCardInfo(content)
const rel = relative(docsRoot, filePath)
return {
id: makeId(getScalar(fm, 'permalink'), filePath),
title: getScalar(fm, 'title') || filePath.split(sep).pop()!.replace(/\.md$/, ''),
link: getScalar(fm, 'permalink'),
cover: extractCoverFromContent(content),
description: getScalar(fm, 'description'),
category: extractCategoryFromPath(rel),
tags: getArray(fm, 'tags'),
date: getScalar(fm, 'date').split(' ')[0],
author: {
name: fromCard.name || getScalar(fm, 'author.name') || getScalar(fm, 'author'),
avatar: fromCard.avatar || getScalar(fm, 'author.avatar') || '',
link: fromCard.link || getScalar(fm, 'author.link'),
subtitle: fromCard.subtitle || getScalar(fm, 'author.subtitle') || '',
},
}
}
function generateData(docsRoot: string): Resource[] {
const resourceDir = join(docsRoot, RESOURCE_DIR)
return walk(resourceDir).map((f) => buildResource(f, docsRoot))
.sort((a, b) => a.date < b.date ? 1 : -1) // 最新在前
}
function writeDataFile(docsRoot: string) {
const items = generateData(docsRoot)
writeFileSync(DATA_FILE_ABS, `export const data = ${JSON.stringify(items, null, 2)};`)
}
export function mrdsDataLoader(): Plugin {
const docsRoot = resolve(__dirname, '../../../') // plugins → theme → .vitepress → docs
return {
name: 'mrds-data-loader',
resolveId(id) { if (id === VIRTUAL_ID) return RESOLVED_VIRTUAL_ID },
load(id) {
if (id !== RESOLVED_VIRTUAL_ID) return
if (!existsSync(DATA_FILE_ABS)) writeDataFile(docsRoot)
return `export * from ${JSON.stringify(DATA_FILE_ABS)};`
},
buildStart() { writeDataFile(docsRoot) },
configureServer(server) {
if (!existsSync(DATA_FILE_ABS)) writeDataFile(docsRoot)
server.watcher.add(join(docsRoot, RESOURCE_DIR, '**/*.md'))
const regen = () => {
writeDataFile(docsRoot)
const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID)
if (mod) { server.moduleGraph.invalidateModule(mod); server.ws.send({ type: 'full-reload' }) }
}
server.watcher.on('change', regen)
server.watcher.on('add', regen)
server.watcher.on('unlink', regen)
},
}
}在 config.ts 注册:
import { mrdsDataLoader } from './theme/plugins/mrds-data-loader'
export default defineConfig({
vite: {
plugins: [
llmstxt() as any,
mrdsDataLoader(),
],
},
})TS 类型声明 docs/.vitepress/env.d.ts:
declare module 'virtual:mrds-data' {
export interface MRDSAuthor { name: string; avatar: string; link: string; subtitle: string }
export interface MRDSItem {
id: string; title: string; link: string; cover: string;
description: string; category: string; tags: string[]; date: string;
author: MRDSAuthor
}
export const data: MRDSItem[]
}关键实现点
1. 首屏 HTML 完整(SSG 友好)
核心原则:首屏 HTML 必须已经包含所有默认显示的卡片 DOM,客户端 JS 只切 display 不重新生成 DOM。
<a v-for="item in data" :key="item.id" :href="item.link"
class="mrds-card"
:class="{ 'mrds-card-hidden': !isVisible(item) }">v-for在 SSG 时同步展开所有卡片v-show替代方案:用mrds-card-hiddenclass 控display: none,不破坏 SSR DOM- 不用
v-if(会真删 DOM,首屏 HTML 不完整) - 不用
<ClientOnly>(会闪白屏)
2. 客户端搜索 / 筛选 / 分页 / URL hash
- 搜索:
query双向绑定 +computed过滤 - 分类:
categories从 data 自动去重,按钮用v-for渲染 - 分页:
totalPages=Math.ceil(filtered.length / perPage) - URL hash 同步:
onMounted时readHash(),watch 变化时writeHash(),刷新页面状态保留
3. 暗色模式辉光
浅色 hover 用普通投影(深底色下看不见),深色改用品牌色辉光:
.mrds-card:hover {
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12); /* 浅色 */
}
.dark .mrds-card:hover {
box-shadow:
0 0 0 1px var(--vp-c-brand-1), /* 内描边 */
0 0 24px rgba(64, 158, 255, 0.45), /* 24px 主辉光 */
0 0 48px rgba(64, 158, 255, 0.25); /* 48px 弥散 */
}4. 数据从 frontmatter 自动来
- 单一数据源:.md frontmatter +
<AuthorCard>组件属性 - 自动补全:
cover从<ImageGallery>抓,category从目录名推,avatar/subtitle从<AuthorCard>读 - 加新资源 = 写一个 .md,展示页自动出现
- 分类自动扩展:建新目录
00N.分类名/,顶部分类按钮自动多一个
Bug 修复记录
Bug 1: JSDoc 注释里 **/*.md 被 esbuild 当成块注释结束符
问题:插件文件编译失败,Unexpected "*" at 5:63。
原因:/** ... */ 注释里写了 **/*.md 描述通配符,esbuild 把 **/ 当成块注释结束,后面就被当成 TypeScript 代码。
修复:JSDoc 注释全改成 // 单行注释。
Bug 2: __dirname 跳错 ../
问题:build 时 data.js 是空数组,所有资源都消失。
原因:__dirname 是插件文件目录 docs/.vitepress/theme/plugins/,往上跳应该是 3 次到 docs/,我写了 4 次跳到项目根,导致 resourceDir 路径找不到。
修复:
// 错:resolve(__dirname, '../../../../') → 跳到 /
// 对:resolve(__dirname, '../../../') → 跳到 docs/Bug 3: 虚拟模块在 VitePress SSG 端不解析
问题:dev 模式组件正常,build 后 HTML 里卡片全没(组件 props 是空对象)。
原因:VitePress SSG 用 vite-node 跑服务端渲染,不识别自定义虚拟模块 ID,mrdsData 永远是 undefined,withDefaults 工厂函数返空。
修复:改方案 — build time 写真实 ESM 文件 + 代理虚拟模块。
function writeDataFile(docsRoot: string) {
const items = generateData(docsRoot)
writeFileSync(DATA_FILE_ABS, `export const data = ${JSON.stringify(items, null, 2)};`)
}
// 虚拟模块只是代理,内容从真实文件读
load(id) {
if (id !== RESOLVED_VIRTUAL_ID) return
if (!existsSync(DATA_FILE_ABS)) writeDataFile(docsRoot)
return `export * from ${JSON.stringify(DATA_FILE_ABS)};` // 关键
}docs/.vitepress/theme/data/mrds-data.js 加 .gitignore 排除(build 自动生成,不应 commit)。
Bug 4: CRLF 行尾污染所有字段
问题:伪随机竞马场.md 这条数据全空(其他 6 条正常)。
原因:这个文件用 CRLF(\r\n)行尾,其他 6 个用 LF(\n)。我的 parser yaml.split('\n') 残留 \r,污染所有字段值(每个字段末尾多个 \r)。
修复:
// 错:const lines = yaml.split('\n')
// 对:
const lines = yaml.split(/\r?\n/) // 兼容 LF / CRLF / CRBug 5: 搜索时页面整体水平跳动
问题:搜索时页面整体往右跳一下,清空搜索又跳回来。
原因:搜索结果变化 → 页面高度变 → 垂直滚动条出现/消失 → 视口宽度变 → 整个页面水平跳动。
修复:docs/.vitepress/theme/styles/scrollbar-stable.scss
/* 永远为滚动条预留位置,内容不溢出时槽位也占着,视口宽度恒定 */
html {
scrollbar-gutter: stable;
}@import 进 theme/index.ts 即可,全局生效。
Bug 6: 旧展示页 permalink 在生产环境 404
问题:VitePress permalink 路由在 dev mode work(SPA 客户端 router),但 production build 后所有 /mcdr/* /mrds 直接访问都 404。dist 里实际 HTML 在 004.Mcoo Resource Download Site/001.红石/...(中文路径),permalink 跟 dist 路径不对应。
修复 1 — 404.html trick(临时方案):
// package.json scripts
{
"docs:build": "vitepress build docs && cp docs/.vitepress/dist/index.html docs/.vitepress/dist/404.html"
}这样任何 404 路径 → 404.html(= index.html)→ 客户端 router 接管。状态码 404,内容对。
修复 2 — Cloudflare Pages Functions(治本,推荐):
functions/[[path]].ts:
export const onRequest: any = async (context: any) => {
const url: URL = new URL(context.request.url)
const response: Response = await context.next()
if (response.status !== 404) return response
// 路径有后缀(图片/CSS/JS)→ 真找不到,保持 404
if (/\.[a-zA-Z0-9]+$/.test(url.pathname)) return response
// 路径无后缀 → SPA fallback,返 200 + index.html
return context.env.ASSETS.fetch(new URL('/index.html', url))
}Functions 优先级最高,比 _redirects 早执行。任意找不到的 permalink 路径都返 HTTP 200 + index.html(Vue Router 接管),彻底解决状态码 404 问题。
全站右键屏蔽(顺便做的)
资源后续要托管到腾讯云省流量,顺手做了全站屏蔽右键菜单。docs/.vitepress/theme/index.ts 的 enhanceApp 钩子:
enhanceApp({ app }: { app: any }) {
// ... 组件注册 ...
if (typeof document !== 'undefined') {
const blockEvents = () => {
// 全站 contextmenu,放过可交互元素
document.addEventListener('contextmenu', (e) => {
const target = e.target as HTMLElement | null
if (target?.matches('input, textarea, [contenteditable], a, button')) return
e.preventDefault()
return false
}, true)
// 拖拽图片屏蔽
document.addEventListener('dragstart', (e) => {
const target = e.target as HTMLElement | null
if (target?.tagName === 'IMG') { e.preventDefault(); return false }
}, true)
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', blockEvents, { once: true })
} else {
blockEvents()
}
}
}绕过方法:disable JS、view-source、PrintScreen —— 真正防护靠 CDN Referer 白名单(腾讯云 COS / CDN 控制台勾一下即可)。
参数说明
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| data | Resource[] | 否 | 资源列表,不传则从 virtual:mrds-data 默认读 |
| perPage | number | 否 | 每页显示几个,默认 12(3×4 桌面 / 2×6 移动) |
Resource 类型:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 唯一 id(从 permalink 或文件名推) |
| title | string | 标题 |
| link | string | 详情页 permalink |
| cover | string | 封面图 URL |
| description | string | 简介 |
| category | string | 分类(从目录名推) |
| tags | string[] | 标签数组 |
| date | string | 发布日期 |
| author | 作者信息 |
效果展示
- 搜索:输入关键字,实时过滤 title / description / author / tags
- 分类筛选:顶部按钮,数据多了自动多出分类(建新目录即可)
- 分页:数据 > perPage 时出现翻页器,URL hash 同步
- 首屏:HTML 24KB,~24KB 内含完整卡片(7 个资源时);无 client fetch,无白屏
- 暗色模式:卡片 hover 抬升 + 品牌色辉光
- 自适应:标题 2 行 / 简介 3 行(line-clamp);flex 填充保持卡片高度统一
- 自动扩展:加新分类(建目录)→ 顶部分类按钮自动多
- permalink:SPA fallback 返 200,直接打开链接/分享/刷新都正常
- 数据流:build time 自动从 .md frontmatter + AuthorCard 拉取,加新资源只改 1 处
提交记录
b85065cfeat(MRDS): 重构资源展示页 + 数据流插件 + 修 permalink 404c7c0086feat(MRDS): 副标题改写 + 新投稿 008(掘一死战) + 深色模式卡片辉光cf64c7ffix(404): 把 404.html 覆盖为 index.html 实现 SPA fallback7055044feat(functions): Cloudflare Pages Function 实现 SPA fallback 返 2000fdfc98feat(theme): 全站屏蔽右键菜单(放过 input/textarea/a/button)
关联文档
- 投稿 Skill:
mrds-submissionv5(7 步流程,已 apply)— 删了 imgCard 维护那 3 步,因为展示页自动同步 - 系列文章:
029.作者卡片组件开发、030.下载卡片组件开发、031.自助投稿页面开发、032.瀑布流相册组件开发 - 工作日志:
memory/2026-06-17.md第七节(MRDSGrid + 展示页迁移完整记录)