#

自助投稿页面开发

By DoubleSpirit121 1 Views 56 MIN READ 0 Comments
详细介绍如何开发一个高性能的自助投稿表单组件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) + '...' 
  : cleanDesc

7. 本地时间生成

使用本地时间而非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
})

参数说明

字段类型必填说明
bvNumberstringB站视频BV号
workNamestring作品名称
resourceTypestring资源类型(redstone/building)
authorNamestring作者名称
gameIdstring游戏ID,用于匹配头像
authorLinkstring作者个人主页链接
workDescriptionstring作品介绍
licensestring许可证类型
imagesarray轮播图数组,最多5个
downloadsarray下载链接数组

本文由 DoubleSpirit121 原创

采用 CC BY-NC-SA 4.0 协议进行许可

转载请注明出处:https://blog.mcoo.top/index.php/archives/42/

加入 Mcoo

0 评论

发表评论