#
VitePress真是太好用了
2026-02-23
自助投稿页面开发
详细介绍如何开发一个高性能的自助投稿表单组件SubmissionForm,用于在VitePress中收集用户投稿资源。涵盖组件需求分析、Vue3+TypeScript代码实现、组件注册方法、动态Markdown生成、头像自动匹配、图片与下载链接动态增删、表单验证等核心功能的完整开发流程。
自助投稿页面开发
组件需求
为了方便用户自助投稿资源到资源下载页面,我们需要开发一个投稿表单组件,满足以下需求:
- 收集作品信息(作品名、资源类型)
- 支持可选的B站视频嵌入
- 收集作者信息(作者名、游戏ID、个人主页链接)
- 游戏ID自动匹配服务器成员头像
- 轮播图支持动态增删(最多5张)
- 作品介绍支持换行
- 下载链接支持动态增删,每个链接可设置介绍
- 根据链接类型自动识别下载名称
- 许可证选择
- 一键生成并下载MD文件
- 自动生成符合规范的Frontmatter
组件实现
创建文件 docs/.vitepress/theme/components/SubmissionForm.vue:
<script setup lang="ts">
import { ref, computed } from 'vue'
const bvNumber = ref('')
const workName = ref('')
const resourceType = ref<'redstone' | 'building'>('redstone')
const authorName = ref('')
const gameId = ref('')
const authorLink = ref('')
const workDescription = ref('')
const license = ref('CC BY-NC-SA 4.0')
const images = ref<{ src: string; alt: string }[]>([{ src: '', alt: '' }])
const downloads = ref<{ url: string; description: string }[]>([{ url: '', description: '' }])
const addImage = () => {
if (images.value.length < 5) {
images.value.push({ src: '', alt: '' })
}
}
const removeImage = (index: number) => {
if (images.value.length > 1) {
images.value.splice(index, 1)
}
}
const addDownload = () => {
downloads.value.push({ url: '', description: '' })
}
const removeDownload = (index: number) => {
if (downloads.value.length > 1) {
downloads.value.splice(index, 1)
}
}
const avatarPath = computed(() => {
if (gameId.value) {
return `/mcoo/${gameId.value}.png`
}
return ''
})
const showAvatarPreview = computed(() => {
return !!gameId.value
})
const handleAvatarError = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
const generateMarkdown = () => {
const now = new Date()
const dateStr = now.getFullYear() + '-' +
String(now.getMonth() + 1).padStart(2, '0') + '-' +
String(now.getDate()).padStart(2, '0') + ' ' +
String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0')
const tag = resourceType.value === 'redstone' ? '红石' : '建筑'
const cleanDesc = workDescription.value.replace(/\n/g, ' ').replace(/<br>/g, ' ').trim()
const desc = cleanDesc.length > 150
? cleanDesc.substring(0, 150) + '...'
: cleanDesc
let md = '---\n'
md += `title: ${workName.value}\n`
md += `date: ${dateStr}\n`
md += 'permalink: \n'
md += 'categories:\n'
md += ' - 资源\n'
md += 'tags:\n'
md += ` - ${tag}\n`
md += `description: ${desc}\n`
md += 'author:\n'
md += ` name: ${authorName.value}\n`
md += ` link: ${authorLink.value}\n`
md += '---\n\n'
if (bvNumber.value) {
md += `<BiliVideo bv="${bvNumber.value}" />\n\n`
}
const validImages = images.value.filter(img => img.src.trim())
if (validImages.length > 0) {
md += '<ImageGallery :images="[\n'
validImages.forEach((img, index) => {
md += ` { src: '${img.src}', alt: '${img.alt || `图片${index + 1}`}' },\n`
})
md += ']" />\n\n'
}
md += `<AuthorCard \n`
md += ` avatar="${avatarPath.value}" \n`
md += ` name="${authorName.value}" \n`
md += ` link="${authorLink.value}" \n`
md += ` subtitle="${gameId.value}"\n`
md += ` license="${license.value}" \n`
md += '>\n'
md += ' <template #description>\n'
const descLines = workDescription.value.split('\n')
descLines.forEach(line => {
if (line.trim()) {
md += ` ${line.trim()}<br>\n`
} else {
md += ' <br>\n'
}
})
md += ' </template>\n'
md += '</AuthorCard>\n\n'
md += '## 作品下载\n\n'
md += '<DownloadCard :downloads="[\n'
const validDownloads = downloads.value.filter(d => d.url.trim())
validDownloads.forEach(download => {
const name = getDownloadName(download.url)
md += ` { name: '${name}', url: '${download.url}', description: '${download.description}' },\n`
})
md += ']" />\n'
return md
}
const getDownloadName = (url: string) => {
if (url.includes('github.com') && !url.includes('gh-proxy')) {
return 'Github'
} else if (url.includes('gh-proxy')) {
return 'Github 加速'
} else if (url.includes('gitee.com')) {
return 'Gitee'
} else if (url.includes('pan.baidu.com') || url.includes('baidu.com')) {
return '百度网盘'
} else if (url.includes('qm.qq.com') || url.includes('qq.com')) {
return 'Mcoo 大型咕咕集散中心'
} else if (url.includes('bilibili.com') || url.includes('b23.tv')) {
return 'B站视频'
}
return '下载链接'
}
const downloadMd = () => {
const markdown = generateMarkdown()
const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${workName.value || '作品'}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const isValid = computed(() => {
const hasValidImages = images.value.some(img => img.src.trim())
const hasValidDownloads = downloads.value.some(d => d.url.trim())
return workName.value.trim() &&
authorName.value.trim() &&
gameId.value.trim() &&
authorLink.value.trim() &&
workDescription.value.trim() &&
hasValidImages &&
hasValidDownloads
})
</script>
<template>
<div class="submission-form">
<div class="form-section">
<h3>作品信息</h3>
<div class="form-group">
<label>作品名 <span class="required">*</span></label>
<input v-model="workName" type="text" placeholder="输入作品名称" class="form-input" />
</div>
<div class="form-group">
<label>资源类型 <span class="required">*</span></label>
<select v-model="resourceType" class="form-input">
<option value="redstone">红石</option>
<option value="building">建筑</option>
</select>
</div>
</div>
<div class="form-section">
<h3>B站视频(可选)</h3>
<input v-model="bvNumber" type="text" placeholder="输入BV号" class="form-input" />
</div>
<div class="form-section">
<h3>作者信息</h3>
<div class="form-group">
<label>作者名 <span class="required">*</span></label>
<input v-model="authorName" type="text" placeholder="输入作者名称" class="form-input" />
</div>
<div class="form-group">
<label>游戏ID <span class="required">*</span></label>
<input v-model="gameId" type="text" placeholder="输入游戏ID,用于匹配头像" class="form-input" />
<p v-if="showAvatarPreview" class="avatar-preview">
头像预览:<img :src="avatarPath" alt="头像预览" @error="handleAvatarError" />
</p>
</div>
<div class="form-group">
<label>作者个人主页链接 <span class="required">*</span></label>
<input v-model="authorLink" type="url" placeholder="输入作者个人主页链接" class="form-input" />
</div>
<div class="form-group">
<label>许可证 <span class="required">*</span></label>
<select v-model="license" class="form-input">
<option value="CC BY-NC-SA 4.0">CC BY-NC-SA 4.0</option>
<option value="CC BY-SA 4.0">CC BY-SA 4.0</option>
<option value="MIT">MIT</option>
</select>
</div>
</div>
<div class="form-section">
<h3>轮播图 <span class="required">*</span>(最多5张)</h3>
<div v-for="(img, index) in images" :key="index" class="image-input-group">
<input v-model="img.src" type="url" placeholder="图片链接" class="form-input" />
<input v-model="img.alt" type="text" placeholder="图片名称(可选)" class="form-input" />
<button v-if="images.length > 1" @click="removeImage(index)" class="btn-remove" type="button">×</button>
</div>
<button v-if="images.length < 5" @click="addImage" class="btn-add" type="button">+ 添加图片</button>
</div>
<div class="form-section">
<h3>作品介绍 <span class="required">*</span></h3>
<textarea v-model="workDescription" placeholder="输入作品介绍,支持换行" class="form-textarea" rows="6"></textarea>
</div>
<div class="form-section">
<h3>下载链接 <span class="required">*</span></h3>
<div v-for="(download, index) in downloads" :key="index" class="download-input-group">
<input v-model="download.url" type="url" placeholder="下载链接" class="form-input" />
<input v-model="download.description" type="text" placeholder="链接描述" class="form-input" />
<button v-if="downloads.length > 1" @click="removeDownload(index)" class="btn-remove" type="button">×</button>
</div>
<button @click="addDownload" class="btn-add" type="button">+ 添加下载链接</button>
</div>
<div class="form-actions">
<button @click="downloadMd" class="btn-download" :disabled="!isValid" type="button">📥 下载 MD 文件</button>
<p v-if="!isValid" class="hint">请填写所有必填项后才能下载</p>
</div>
</div>
</template>
<style scoped lang="scss">
.submission-form {
max-width: 800px;
margin: 0 auto;
padding: 24px;
background: var(--vp-c-bg-soft);
border-radius: 12px;
}
.form-section {
margin-bottom: 24px;
h3 { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; }
}
.form-group {
margin-bottom: 16px;
label { display: block; margin-bottom: 6px; font-size: 0.95rem; }
.required { color: #e74c3c; }
}
.form-input, .form-textarea {
width: 100%;
padding: 10px 14px;
font-size: 0.95rem;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
&:focus { outline: none; border-color: var(--vp-c-brand); }
}
.image-input-group, .download-input-group {
display: flex; gap: 10px; margin-bottom: 10px;
.form-input { flex: 1; }
}
.btn-add {
padding: 8px 16px; font-size: 0.9rem;
color: var(--vp-c-brand); background: transparent;
border: 1px dashed var(--vp-c-brand); border-radius: 8px; cursor: pointer;
}
.btn-remove {
padding: 8px 14px; font-size: 1rem;
color: #e74c3c; background: transparent;
border: 1px solid #e74c3c; border-radius: 8px; cursor: pointer;
}
.avatar-preview {
margin-top: 8px; font-size: 0.9rem;
img { width: 48px; height: 48px; border-radius: 50%; vertical-align: middle; margin-left: 8px; }
}
.btn-download {
padding: 12px 32px; font-size: 1rem; font-weight: 600;
color: #fff; background: var(--vp-c-brand);
border: none; border-radius: 8px; cursor: pointer;
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
</style>组件注册
在 docs/.vitepress/theme/index.ts 中注册组件:
import SubmissionForm from "./components/SubmissionForm.vue";
export default {
enhanceApp({ app }) {
app.component('SubmissionForm', SubmissionForm);
},
}页面使用
创建 docs/@pages/自助投稿.md:
---
title: 自助投稿
permalink: /submission
---
<SubmissionForm />关键实现点
1. 动态增删图片和下载链接
使用数组和动态添加/删除函数实现:
const images = ref<{ src: string; alt: string }[]>([{ src: '', alt: '' }])
const addImage = () => {
if (images.value.length < 5) {
images.value.push({ src: '', alt: '' })
}
}
const removeImage = (index: number) => {
if (images.value.length > 1) {
images.value.splice(index, 1)
}
}2. 游戏ID自动匹配头像
使用 computed 属性根据游戏ID动态计算头像路径:
const avatarPath = computed(() => {
if (gameId.value) {
return `/mcoo/${gameId.value}.png`
}
return ''
})
const showAvatarPreview = computed(() => {
return !!gameId.value
})注意:这里需要使用 computed 属性来包装条件判断,直接在模板中使用 v-if="gameId" 可能不会正确触发响应式更新。
3. 头像加载错误处理
当头像文件不存在时隐藏图片:
const handleAvatarError = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}4. 下载链接自动识别名称
根据链接类型自动识别并生成下载名称:
const getDownloadName = (url: string) => {
if (url.includes('github.com') && !url.includes('gh-proxy')) {
return 'Github'
} else if (url.includes('gh-proxy')) {
return 'Github 加速'
} else if (url.includes('pan.baidu.com') || url.includes('baidu.com')) {
return '百度网盘'
} else if (url.includes('qm.qq.com') || url.includes('qq.com')) {
return 'Mcoo 大型咕咕集散中心'
}
return '下载链接'
}5. 生成带换行的Description
作品介绍需要支持换行,在生成MD时转换为 <br> 标签:
const descLines = workDescription.value.split('\n')
descLines.forEach(line => {
if (line.trim()) {
md += ` ${line.trim()}<br>\n`
} else {
md += ' <br>\n'
}
})6. Description自动处理
将换行符替换为空格,并截取前150字:
const cleanDesc = workDescription.value.replace(/\n/g, ' ').replace(/<br>/g, ' ').trim()
const desc = cleanDesc.length > 150
? cleanDesc.substring(0, 150) + '...'
: cleanDesc7. 本地时间生成
使用本地时间而非UTC时间:
const dateStr = now.getFullYear() + '-' +
String(now.getMonth() + 1).padStart(2, '0') + '-' +
String(now.getDate()).padStart(2, '0') + ' ' +
String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0')8. 表单验证
使用 computed 属性进行表单验证:
const isValid = computed(() => {
const hasValidImages = images.value.some(img => img.src.trim())
const hasValidDownloads = downloads.value.some(d => d.url.trim())
return workName.value.trim() &&
authorName.value.trim() &&
gameId.value.trim() &&
authorLink.value.trim() &&
workDescription.value.trim() &&
hasValidImages &&
hasValidDownloads
})参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| bvNumber | string | 否 | B站视频BV号 |
| workName | string | 是 | 作品名称 |
| resourceType | string | 是 | 资源类型(redstone/building) |
| authorName | string | 是 | 作者名称 |
| gameId | string | 是 | 游戏ID,用于匹配头像 |
| authorLink | string | 是 | 作者个人主页链接 |
| workDescription | string | 是 | 作品介绍 |
| license | string | 是 | 许可证类型 |
| images | array | 是 | 轮播图数组,最多5个 |
| downloads | array | 是 | 下载链接数组 |
本文由 DoubleSpirit121 原创
采用 CC BY-NC-SA 4.0 协议进行许可
TAGS:
VitePress 1.6.3
0 评论